1use ferrolearn_core::error::FerroError;
71use ferrolearn_core::introspection::{HasClasses, HasFeatureImportances};
72use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
73use ferrolearn_core::traits::{Fit, Predict};
74use ndarray::{Array1, Array2};
75use num_traits::{Float, FromPrimitive, ToPrimitive};
76use rand::SeedableRng;
77use rand::rngs::StdRng;
78use rand::seq::index::sample as rand_sample_indices;
79
80use crate::decision_tree::{
81 self, Node, build_regression_tree_with_feature_subset, compute_feature_importances,
82};
83
84fn reject_non_finite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
94 if x.iter().any(|v| !v.is_finite()) {
95 return Err(FerroError::InvalidParameter {
96 name: "X".into(),
97 reason: "Input X contains NaN or infinity.".into(),
98 });
99 }
100 Ok(())
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum RegressionLoss {
110 LeastSquares,
112 Lad,
114 Huber,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum ClassificationLoss {
121 LogLoss,
123}
124
125#[derive(Debug, Clone)]
139pub struct GradientBoostingRegressor<F> {
140 pub n_estimators: usize,
142 pub learning_rate: f64,
144 pub max_depth: Option<usize>,
146 pub min_samples_split: usize,
148 pub min_samples_leaf: usize,
150 pub subsample: f64,
152 pub loss: RegressionLoss,
154 pub huber_alpha: f64,
156 pub random_state: Option<u64>,
158 _marker: std::marker::PhantomData<F>,
159}
160
161impl<F: Float> GradientBoostingRegressor<F> {
162 #[must_use]
169 pub fn new() -> Self {
170 Self {
171 n_estimators: 100,
172 learning_rate: 0.1,
173 max_depth: Some(3),
174 min_samples_split: 2,
175 min_samples_leaf: 1,
176 subsample: 1.0,
177 loss: RegressionLoss::LeastSquares,
178 huber_alpha: 0.9,
179 random_state: None,
180 _marker: std::marker::PhantomData,
181 }
182 }
183
184 #[must_use]
186 pub fn with_n_estimators(mut self, n: usize) -> Self {
187 self.n_estimators = n;
188 self
189 }
190
191 #[must_use]
193 pub fn with_learning_rate(mut self, lr: f64) -> Self {
194 self.learning_rate = lr;
195 self
196 }
197
198 #[must_use]
200 pub fn with_max_depth(mut self, d: Option<usize>) -> Self {
201 self.max_depth = d;
202 self
203 }
204
205 #[must_use]
207 pub fn with_min_samples_split(mut self, n: usize) -> Self {
208 self.min_samples_split = n;
209 self
210 }
211
212 #[must_use]
214 pub fn with_min_samples_leaf(mut self, n: usize) -> Self {
215 self.min_samples_leaf = n;
216 self
217 }
218
219 #[must_use]
221 pub fn with_subsample(mut self, ratio: f64) -> Self {
222 self.subsample = ratio;
223 self
224 }
225
226 #[must_use]
228 pub fn with_loss(mut self, loss: RegressionLoss) -> Self {
229 self.loss = loss;
230 self
231 }
232
233 #[must_use]
235 pub fn with_huber_alpha(mut self, alpha: f64) -> Self {
236 self.huber_alpha = alpha;
237 self
238 }
239
240 #[must_use]
242 pub fn with_random_state(mut self, seed: u64) -> Self {
243 self.random_state = Some(seed);
244 self
245 }
246}
247
248impl<F: Float> Default for GradientBoostingRegressor<F> {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254#[derive(Debug, Clone)]
263pub struct FittedGradientBoostingRegressor<F> {
264 init: F,
266 learning_rate: F,
268 trees: Vec<Vec<Node<F>>>,
270 n_features: usize,
272 feature_importances: Array1<F>,
274}
275
276impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for GradientBoostingRegressor<F> {
277 type Fitted = FittedGradientBoostingRegressor<F>;
278 type Error = FerroError;
279
280 fn fit(
289 &self,
290 x: &Array2<F>,
291 y: &Array1<F>,
292 ) -> Result<FittedGradientBoostingRegressor<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 if n_samples == 0 {
303 return Err(FerroError::InsufficientSamples {
304 required: 1,
305 actual: 0,
306 context: "GradientBoostingRegressor requires at least one sample".into(),
307 });
308 }
309 if self.n_estimators == 0 {
310 return Err(FerroError::InvalidParameter {
311 name: "n_estimators".into(),
312 reason: "must be at least 1".into(),
313 });
314 }
315 if self.learning_rate <= 0.0 {
316 return Err(FerroError::InvalidParameter {
317 name: "learning_rate".into(),
318 reason: "must be positive".into(),
319 });
320 }
321 if self.subsample <= 0.0 || self.subsample > 1.0 {
322 return Err(FerroError::InvalidParameter {
323 name: "subsample".into(),
324 reason: "must be in (0, 1]".into(),
325 });
326 }
327 reject_non_finite(x)?;
330 if y.iter().any(|v| !v.is_finite()) {
331 return Err(FerroError::InvalidParameter {
332 name: "y".into(),
333 reason: "Input y contains NaN or infinity.".into(),
334 });
335 }
336
337 let lr = F::from(self.learning_rate).unwrap_or_else(F::one);
340 let params = decision_tree::TreeParams {
341 max_depth: self.max_depth,
342 min_samples_split: self.min_samples_split,
343 min_samples_leaf: self.min_samples_leaf,
344 };
345
346 let init = match self.loss {
348 RegressionLoss::LeastSquares => {
349 let sum: F = y.iter().copied().fold(F::zero(), |a, b| a + b);
350 sum / F::from(n_samples).unwrap()
351 }
352 RegressionLoss::Lad | RegressionLoss::Huber => median_f(y),
353 };
354
355 let mut f_vals = Array1::from_elem(n_samples, init);
357
358 let all_features: Vec<usize> = (0..n_features).collect();
359 let subsample_size = ((self.subsample * n_samples as f64).ceil() as usize)
360 .max(1)
361 .min(n_samples);
362
363 let mut rng = if let Some(seed) = self.random_state {
364 StdRng::seed_from_u64(seed)
365 } else {
366 use rand::RngCore;
367 StdRng::seed_from_u64(rand::rng().next_u64())
368 };
369
370 let mut trees = Vec::with_capacity(self.n_estimators);
371
372 for _ in 0..self.n_estimators {
373 let residuals = compute_regression_residuals(y, &f_vals, self.loss, self.huber_alpha);
375
376 let sample_indices = if subsample_size < n_samples {
378 rand_sample_indices(&mut rng, n_samples, subsample_size).into_vec()
379 } else {
380 (0..n_samples).collect()
381 };
382
383 let mut tree = build_regression_tree_with_feature_subset(
385 x,
386 &residuals,
387 &sample_indices,
388 &all_features,
389 ¶ms,
390 );
391
392 match self.loss {
398 RegressionLoss::LeastSquares => {}
399 RegressionLoss::Lad => {
400 let groups = group_samples_by_leaf(&tree, x, &sample_indices);
401 for (&leaf_idx, leaf_samples) in &groups {
402 let v = lad_leaf_value(y, &f_vals, leaf_samples);
403 if let Node::Leaf { value, .. } = &mut tree[leaf_idx] {
404 *value = v;
405 }
406 }
407 }
408 RegressionLoss::Huber => {
409 let delta = huber_stage_delta(y, &f_vals, &sample_indices, self.huber_alpha);
410 let groups = group_samples_by_leaf(&tree, x, &sample_indices);
411 for (&leaf_idx, leaf_samples) in &groups {
412 let v = huber_leaf_value(y, &f_vals, leaf_samples, delta);
413 if let Node::Leaf { value, .. } = &mut tree[leaf_idx] {
414 *value = v;
415 }
416 }
417 }
418 }
419
420 for i in 0..n_samples {
422 let row = x.row(i);
423 let leaf_idx = decision_tree::traverse(&tree, &row);
424 if let Node::Leaf { value, .. } = tree[leaf_idx] {
425 f_vals[i] = f_vals[i] + lr * value;
426 }
427 }
428
429 trees.push(tree);
430 }
431
432 let mut total_importances = Array1::<F>::zeros(n_features);
434 for tree_nodes in &trees {
435 let tree_imp = compute_feature_importances(tree_nodes, n_features, n_samples);
436 total_importances = total_importances + tree_imp;
437 }
438 let imp_sum: F = total_importances
439 .iter()
440 .copied()
441 .fold(F::zero(), |a, b| a + b);
442 if imp_sum > F::zero() {
443 total_importances.mapv_inplace(|v| v / imp_sum);
444 }
445
446 Ok(FittedGradientBoostingRegressor {
447 init,
448 learning_rate: lr,
449 trees,
450 n_features,
451 feature_importances: total_importances,
452 })
453 }
454}
455
456impl<F: Float + Send + Sync + 'static> FittedGradientBoostingRegressor<F> {
457 #[must_use]
459 pub fn init(&self) -> F {
460 self.init
461 }
462
463 #[must_use]
465 pub fn learning_rate(&self) -> F {
466 self.learning_rate
467 }
468
469 #[must_use]
471 pub fn trees(&self) -> &[Vec<Node<F>>] {
472 &self.trees
473 }
474
475 #[must_use]
477 pub fn n_features(&self) -> usize {
478 self.n_features
479 }
480
481 pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
489 if x.nrows() != y.len() {
490 return Err(FerroError::ShapeMismatch {
491 expected: vec![x.nrows()],
492 actual: vec![y.len()],
493 context: "y length must match number of samples in X".into(),
494 });
495 }
496 let preds = self.predict(x)?;
497 Ok(crate::r2_score(&preds, y))
498 }
499}
500
501impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedGradientBoostingRegressor<F> {
502 type Output = Array1<F>;
503 type Error = FerroError;
504
505 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
512 if x.ncols() != self.n_features {
513 return Err(FerroError::ShapeMismatch {
514 expected: vec![self.n_features],
515 actual: vec![x.ncols()],
516 context: "number of features must match fitted model".into(),
517 });
518 }
519
520 let n_samples = x.nrows();
521 let mut predictions = Array1::from_elem(n_samples, self.init);
522
523 for i in 0..n_samples {
524 let row = x.row(i);
525 for tree_nodes in &self.trees {
526 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
527 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
528 predictions[i] = predictions[i] + self.learning_rate * value;
529 }
530 }
531 }
532
533 Ok(predictions)
534 }
535}
536
537impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F>
538 for FittedGradientBoostingRegressor<F>
539{
540 fn feature_importances(&self) -> &Array1<F> {
541 &self.feature_importances
542 }
543}
544
545impl<F: Float + Send + Sync + 'static> PipelineEstimator<F> for GradientBoostingRegressor<F> {
547 fn fit_pipeline(
548 &self,
549 x: &Array2<F>,
550 y: &Array1<F>,
551 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
552 let fitted = self.fit(x, y)?;
553 Ok(Box::new(fitted))
554 }
555}
556
557impl<F: Float + Send + Sync + 'static> FittedPipelineEstimator<F>
558 for FittedGradientBoostingRegressor<F>
559{
560 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
561 self.predict(x)
562 }
563}
564
565#[derive(Debug, Clone)]
579pub struct GradientBoostingClassifier<F> {
580 pub n_estimators: usize,
582 pub learning_rate: f64,
584 pub max_depth: Option<usize>,
586 pub min_samples_split: usize,
588 pub min_samples_leaf: usize,
590 pub subsample: f64,
592 pub loss: ClassificationLoss,
594 pub random_state: Option<u64>,
596 _marker: std::marker::PhantomData<F>,
597}
598
599impl<F: Float> GradientBoostingClassifier<F> {
600 #[must_use]
607 pub fn new() -> Self {
608 Self {
609 n_estimators: 100,
610 learning_rate: 0.1,
611 max_depth: Some(3),
612 min_samples_split: 2,
613 min_samples_leaf: 1,
614 subsample: 1.0,
615 loss: ClassificationLoss::LogLoss,
616 random_state: None,
617 _marker: std::marker::PhantomData,
618 }
619 }
620
621 #[must_use]
623 pub fn with_n_estimators(mut self, n: usize) -> Self {
624 self.n_estimators = n;
625 self
626 }
627
628 #[must_use]
630 pub fn with_learning_rate(mut self, lr: f64) -> Self {
631 self.learning_rate = lr;
632 self
633 }
634
635 #[must_use]
637 pub fn with_max_depth(mut self, d: Option<usize>) -> Self {
638 self.max_depth = d;
639 self
640 }
641
642 #[must_use]
644 pub fn with_min_samples_split(mut self, n: usize) -> Self {
645 self.min_samples_split = n;
646 self
647 }
648
649 #[must_use]
651 pub fn with_min_samples_leaf(mut self, n: usize) -> Self {
652 self.min_samples_leaf = n;
653 self
654 }
655
656 #[must_use]
658 pub fn with_subsample(mut self, ratio: f64) -> Self {
659 self.subsample = ratio;
660 self
661 }
662
663 #[must_use]
665 pub fn with_random_state(mut self, seed: u64) -> Self {
666 self.random_state = Some(seed);
667 self
668 }
669}
670
671impl<F: Float> Default for GradientBoostingClassifier<F> {
672 fn default() -> Self {
673 Self::new()
674 }
675}
676
677#[derive(Debug, Clone)]
686pub struct FittedGradientBoostingClassifier<F> {
687 classes: Vec<usize>,
689 init: Vec<F>,
691 learning_rate: F,
693 trees: Vec<Vec<Vec<Node<F>>>>,
696 n_features: usize,
698 feature_importances: Array1<F>,
700}
701
702impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>>
703 for GradientBoostingClassifier<F>
704{
705 type Fitted = FittedGradientBoostingClassifier<F>;
706 type Error = FerroError;
707
708 fn fit(
717 &self,
718 x: &Array2<F>,
719 y: &Array1<usize>,
720 ) -> Result<FittedGradientBoostingClassifier<F>, FerroError> {
721 let (n_samples, n_features) = x.dim();
722
723 if n_samples != y.len() {
724 return Err(FerroError::ShapeMismatch {
725 expected: vec![n_samples],
726 actual: vec![y.len()],
727 context: "y length must match number of samples in X".into(),
728 });
729 }
730 if n_samples == 0 {
731 return Err(FerroError::InsufficientSamples {
732 required: 1,
733 actual: 0,
734 context: "GradientBoostingClassifier requires at least one sample".into(),
735 });
736 }
737 if self.n_estimators == 0 {
738 return Err(FerroError::InvalidParameter {
739 name: "n_estimators".into(),
740 reason: "must be at least 1".into(),
741 });
742 }
743 if self.learning_rate <= 0.0 {
744 return Err(FerroError::InvalidParameter {
745 name: "learning_rate".into(),
746 reason: "must be positive".into(),
747 });
748 }
749 if self.subsample <= 0.0 || self.subsample > 1.0 {
750 return Err(FerroError::InvalidParameter {
751 name: "subsample".into(),
752 reason: "must be in (0, 1]".into(),
753 });
754 }
755 reject_non_finite(x)?;
758
759 let mut classes: Vec<usize> = y.iter().copied().collect();
761 classes.sort_unstable();
762 classes.dedup();
763 let n_classes = classes.len();
764
765 if n_classes < 2 {
766 return Err(FerroError::InvalidParameter {
767 name: "y".into(),
768 reason: "need at least 2 distinct classes".into(),
769 });
770 }
771
772 let y_mapped: Vec<usize> = y
773 .iter()
774 .map(|&c| classes.iter().position(|&cl| cl == c).unwrap())
775 .collect();
776
777 let lr = F::from(self.learning_rate).unwrap();
778 let params = decision_tree::TreeParams {
779 max_depth: self.max_depth,
780 min_samples_split: self.min_samples_split,
781 min_samples_leaf: self.min_samples_leaf,
782 };
783
784 let all_features: Vec<usize> = (0..n_features).collect();
785 let subsample_size = ((self.subsample * n_samples as f64).ceil() as usize)
786 .max(1)
787 .min(n_samples);
788
789 let mut rng = if let Some(seed) = self.random_state {
790 StdRng::seed_from_u64(seed)
791 } else {
792 use rand::RngCore;
793 StdRng::seed_from_u64(rand::rng().next_u64())
794 };
795
796 if n_classes == 2 {
797 self.fit_binary(
799 x,
800 &y_mapped,
801 n_samples,
802 n_features,
803 &classes,
804 lr,
805 ¶ms,
806 &all_features,
807 subsample_size,
808 &mut rng,
809 )
810 } else {
811 self.fit_multiclass(
813 x,
814 &y_mapped,
815 n_samples,
816 n_features,
817 n_classes,
818 &classes,
819 lr,
820 ¶ms,
821 &all_features,
822 subsample_size,
823 &mut rng,
824 )
825 }
826 }
827}
828
829impl<F: Float + Send + Sync + 'static> GradientBoostingClassifier<F> {
830 #[allow(clippy::too_many_arguments)]
832 fn fit_binary(
833 &self,
834 x: &Array2<F>,
835 y_mapped: &[usize],
836 n_samples: usize,
837 n_features: usize,
838 classes: &[usize],
839 lr: F,
840 params: &decision_tree::TreeParams,
841 all_features: &[usize],
842 subsample_size: usize,
843 rng: &mut StdRng,
844 ) -> Result<FittedGradientBoostingClassifier<F>, FerroError> {
845 let pos_count = y_mapped.iter().filter(|&&c| c == 1).count();
847 let p = F::from(pos_count).unwrap() / F::from(n_samples).unwrap();
848 let eps = F::from(1e-15).unwrap();
849 let p_clipped = p.max(eps).min(F::one() - eps);
850 let init_val = (p_clipped / (F::one() - p_clipped)).ln();
851
852 let mut f_vals = Array1::from_elem(n_samples, init_val);
853 let mut trees_seq: Vec<Vec<Node<F>>> = Vec::with_capacity(self.n_estimators);
854
855 for _ in 0..self.n_estimators {
856 let probs: Vec<F> = f_vals.iter().map(|&fv| sigmoid(fv)).collect();
858
859 let mut residuals = Array1::zeros(n_samples);
861 for i in 0..n_samples {
862 let yi = F::from(y_mapped[i]).unwrap();
863 residuals[i] = yi - probs[i];
864 }
865
866 let sample_indices = if subsample_size < n_samples {
868 rand_sample_indices(rng, n_samples, subsample_size).into_vec()
869 } else {
870 (0..n_samples).collect()
871 };
872
873 let mut tree = build_regression_tree_with_feature_subset(
875 x,
876 &residuals,
877 &sample_indices,
878 all_features,
879 params,
880 );
881
882 let groups = group_samples_by_leaf(&tree, x, &sample_indices);
886 for (&leaf_idx, leaf_samples) in &groups {
887 let v = binary_newton_leaf(&residuals, &probs, leaf_samples);
888 if let Node::Leaf { value, .. } = &mut tree[leaf_idx] {
889 *value = v;
890 }
891 }
892
893 for i in 0..n_samples {
895 let row = x.row(i);
896 let leaf_idx = decision_tree::traverse(&tree, &row);
897 if let Node::Leaf { value, .. } = tree[leaf_idx] {
898 f_vals[i] = f_vals[i] + lr * value;
899 }
900 }
901
902 trees_seq.push(tree);
903 }
904
905 let mut total_importances = Array1::<F>::zeros(n_features);
907 for tree_nodes in &trees_seq {
908 let tree_imp = compute_feature_importances(tree_nodes, n_features, n_samples);
909 total_importances = total_importances + tree_imp;
910 }
911 let imp_sum: F = total_importances
912 .iter()
913 .copied()
914 .fold(F::zero(), |a, b| a + b);
915 if imp_sum > F::zero() {
916 total_importances.mapv_inplace(|v| v / imp_sum);
917 }
918
919 Ok(FittedGradientBoostingClassifier {
920 classes: classes.to_vec(),
921 init: vec![init_val],
922 learning_rate: lr,
923 trees: vec![trees_seq],
924 n_features,
925 feature_importances: total_importances,
926 })
927 }
928
929 #[allow(clippy::too_many_arguments)]
931 fn fit_multiclass(
932 &self,
933 x: &Array2<F>,
934 y_mapped: &[usize],
935 n_samples: usize,
936 n_features: usize,
937 n_classes: usize,
938 classes: &[usize],
939 lr: F,
940 params: &decision_tree::TreeParams,
941 all_features: &[usize],
942 subsample_size: usize,
943 rng: &mut StdRng,
944 ) -> Result<FittedGradientBoostingClassifier<F>, FerroError> {
945 let mut class_counts = vec![0usize; n_classes];
947 for &c in y_mapped {
948 class_counts[c] += 1;
949 }
950 let n_f = F::from(n_samples).unwrap();
951 let eps = F::from(1e-15).unwrap();
952 let init_vals: Vec<F> = class_counts
953 .iter()
954 .map(|&cnt| {
955 let p = (F::from(cnt).unwrap() / n_f).max(eps);
956 p.ln()
957 })
958 .collect();
959
960 let mut f_vals: Vec<Array1<F>> = init_vals
962 .iter()
963 .map(|&init| Array1::from_elem(n_samples, init))
964 .collect();
965
966 let mut trees_per_class: Vec<Vec<Vec<Node<F>>>> = (0..n_classes)
967 .map(|_| Vec::with_capacity(self.n_estimators))
968 .collect();
969
970 for _ in 0..self.n_estimators {
971 let probs = softmax_matrix(&f_vals, n_samples, n_classes);
973
974 let sample_indices = if subsample_size < n_samples {
976 rand_sample_indices(rng, n_samples, subsample_size).into_vec()
977 } else {
978 (0..n_samples).collect()
979 };
980
981 for k in 0..n_classes {
983 let mut residuals = Array1::zeros(n_samples);
984 for i in 0..n_samples {
985 let yi_k = if y_mapped[i] == k {
986 F::one()
987 } else {
988 F::zero()
989 };
990 residuals[i] = yi_k - probs[k][i];
991 }
992
993 let mut tree = build_regression_tree_with_feature_subset(
994 x,
995 &residuals,
996 &sample_indices,
997 all_features,
998 params,
999 );
1000
1001 let groups = group_samples_by_leaf(&tree, x, &sample_indices);
1006 for (&leaf_idx, leaf_samples) in &groups {
1007 let v = multiclass_newton_leaf(&residuals, &probs[k], leaf_samples, n_classes);
1008 if let Node::Leaf { value, .. } = &mut tree[leaf_idx] {
1009 *value = v;
1010 }
1011 }
1012
1013 for (i, fv) in f_vals[k].iter_mut().enumerate() {
1015 let row = x.row(i);
1016 let leaf_idx = decision_tree::traverse(&tree, &row);
1017 if let Node::Leaf { value, .. } = tree[leaf_idx] {
1018 *fv = *fv + lr * value;
1019 }
1020 }
1021
1022 trees_per_class[k].push(tree);
1023 }
1024 }
1025
1026 let mut total_importances = Array1::<F>::zeros(n_features);
1028 for class_trees in &trees_per_class {
1029 for tree_nodes in class_trees {
1030 let tree_imp = compute_feature_importances(tree_nodes, n_features, n_samples);
1031 total_importances = total_importances + tree_imp;
1032 }
1033 }
1034 let imp_sum: F = total_importances
1035 .iter()
1036 .copied()
1037 .fold(F::zero(), |a, b| a + b);
1038 if imp_sum > F::zero() {
1039 total_importances.mapv_inplace(|v| v / imp_sum);
1040 }
1041
1042 Ok(FittedGradientBoostingClassifier {
1043 classes: classes.to_vec(),
1044 init: init_vals,
1045 learning_rate: lr,
1046 trees: trees_per_class,
1047 n_features,
1048 feature_importances: total_importances,
1049 })
1050 }
1051}
1052
1053impl<F: Float + Send + Sync + 'static> FittedGradientBoostingClassifier<F> {
1054 #[must_use]
1056 pub fn init(&self) -> &[F] {
1057 &self.init
1058 }
1059
1060 #[must_use]
1062 pub fn learning_rate(&self) -> F {
1063 self.learning_rate
1064 }
1065
1066 #[must_use]
1071 pub fn trees(&self) -> &[Vec<Vec<Node<F>>>] {
1072 &self.trees
1073 }
1074
1075 #[must_use]
1077 pub fn n_features(&self) -> usize {
1078 self.n_features
1079 }
1080
1081 pub fn score(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<F, FerroError> {
1089 if x.nrows() != y.len() {
1090 return Err(FerroError::ShapeMismatch {
1091 expected: vec![x.nrows()],
1092 actual: vec![y.len()],
1093 context: "y length must match number of samples in X".into(),
1094 });
1095 }
1096 let preds = self.predict(x)?;
1097 Ok(crate::mean_accuracy(&preds, y))
1098 }
1099
1100 #[allow(clippy::needless_range_loop)] pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1114 if x.ncols() != self.n_features {
1115 return Err(FerroError::ShapeMismatch {
1116 expected: vec![self.n_features],
1117 actual: vec![x.ncols()],
1118 context: "number of features must match fitted model".into(),
1119 });
1120 }
1121 let n_samples = x.nrows();
1122 let n_classes = self.classes.len();
1123 let mut proba = Array2::<F>::zeros((n_samples, n_classes));
1124
1125 if n_classes == 2 {
1126 let init = self.init[0];
1127 for i in 0..n_samples {
1128 let row = x.row(i);
1129 let mut f_val = init;
1130 for tree_nodes in &self.trees[0] {
1131 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1132 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1133 f_val = f_val + self.learning_rate * value;
1134 }
1135 }
1136 let p1 = sigmoid(f_val);
1137 proba[[i, 0]] = F::one() - p1;
1138 proba[[i, 1]] = p1;
1139 }
1140 } else {
1141 for i in 0..n_samples {
1142 let row = x.row(i);
1143 let mut scores = vec![F::zero(); n_classes];
1144 for k in 0..n_classes {
1145 let mut f_val = self.init[k];
1146 for tree_nodes in &self.trees[k] {
1147 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1148 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1149 f_val = f_val + self.learning_rate * value;
1150 }
1151 }
1152 scores[k] = f_val;
1153 }
1154 let max_s = scores
1155 .iter()
1156 .copied()
1157 .fold(F::neg_infinity(), |a, b| if b > a { b } else { a });
1158 let mut sum_exp = F::zero();
1159 for k in 0..n_classes {
1160 let e = (scores[k] - max_s).exp();
1161 proba[[i, k]] = e;
1162 sum_exp = sum_exp + e;
1163 }
1164 if sum_exp > F::zero() {
1165 for k in 0..n_classes {
1166 proba[[i, k]] = proba[[i, k]] / sum_exp;
1167 }
1168 }
1169 }
1170 }
1171 Ok(proba)
1172 }
1173
1174 pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1181 let proba = self.predict_proba(x)?;
1182 Ok(crate::log_proba(&proba))
1183 }
1184
1185 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1198 if x.ncols() != self.n_features {
1199 return Err(FerroError::ShapeMismatch {
1200 expected: vec![self.n_features],
1201 actual: vec![x.ncols()],
1202 context: "number of features must match fitted model".into(),
1203 });
1204 }
1205 let n_samples = x.nrows();
1206 let n_classes = self.classes.len();
1207
1208 if n_classes == 2 {
1209 let init = self.init[0];
1210 let mut out = Array2::<F>::zeros((n_samples, 1));
1211 for i in 0..n_samples {
1212 let row = x.row(i);
1213 let mut f_val = init;
1214 for tree_nodes in &self.trees[0] {
1215 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1216 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1217 f_val = f_val + self.learning_rate * value;
1218 }
1219 }
1220 out[[i, 0]] = f_val;
1221 }
1222 Ok(out)
1223 } else {
1224 let mut out = Array2::<F>::zeros((n_samples, n_classes));
1225 for i in 0..n_samples {
1226 let row = x.row(i);
1227 for k in 0..n_classes {
1228 let mut f_val = self.init[k];
1229 for tree_nodes in &self.trees[k] {
1230 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1231 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1232 f_val = f_val + self.learning_rate * value;
1233 }
1234 }
1235 out[[i, k]] = f_val;
1236 }
1237 }
1238 Ok(out)
1239 }
1240 }
1241}
1242
1243impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedGradientBoostingClassifier<F> {
1244 type Output = Array1<usize>;
1245 type Error = FerroError;
1246
1247 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
1254 if x.ncols() != self.n_features {
1255 return Err(FerroError::ShapeMismatch {
1256 expected: vec![self.n_features],
1257 actual: vec![x.ncols()],
1258 context: "number of features must match fitted model".into(),
1259 });
1260 }
1261
1262 let n_samples = x.nrows();
1263 let n_classes = self.classes.len();
1264
1265 if n_classes == 2 {
1266 let init = self.init[0];
1268 let mut predictions = Array1::zeros(n_samples);
1269 for i in 0..n_samples {
1270 let row = x.row(i);
1271 let mut f_val = init;
1272 for tree_nodes in &self.trees[0] {
1273 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1274 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1275 f_val = f_val + self.learning_rate * value;
1276 }
1277 }
1278 let prob = sigmoid(f_val);
1279 let class_idx = if prob >= F::from(0.5).unwrap() { 1 } else { 0 };
1280 predictions[i] = self.classes[class_idx];
1281 }
1282 Ok(predictions)
1283 } else {
1284 let mut predictions = Array1::zeros(n_samples);
1286 for i in 0..n_samples {
1287 let row = x.row(i);
1288 let mut scores = Vec::with_capacity(n_classes);
1289 for k in 0..n_classes {
1290 let mut f_val = self.init[k];
1291 for tree_nodes in &self.trees[k] {
1292 let leaf_idx = decision_tree::traverse(tree_nodes, &row);
1293 if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
1294 f_val = f_val + self.learning_rate * value;
1295 }
1296 }
1297 scores.push(f_val);
1298 }
1299 let best_k = scores
1300 .iter()
1301 .enumerate()
1302 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1303 .map_or(0, |(k, _)| k);
1304 predictions[i] = self.classes[best_k];
1305 }
1306 Ok(predictions)
1307 }
1308 }
1309}
1310
1311impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F>
1312 for FittedGradientBoostingClassifier<F>
1313{
1314 fn feature_importances(&self) -> &Array1<F> {
1315 &self.feature_importances
1316 }
1317}
1318
1319impl<F: Float + Send + Sync + 'static> HasClasses for FittedGradientBoostingClassifier<F> {
1320 fn classes(&self) -> &[usize] {
1321 &self.classes
1322 }
1323
1324 fn n_classes(&self) -> usize {
1325 self.classes.len()
1326 }
1327}
1328
1329impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> PipelineEstimator<F>
1331 for GradientBoostingClassifier<F>
1332{
1333 fn fit_pipeline(
1334 &self,
1335 x: &Array2<F>,
1336 y: &Array1<F>,
1337 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
1338 let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
1339 let fitted = self.fit(x, &y_usize)?;
1340 Ok(Box::new(FittedGbcPipelineAdapter(fitted)))
1341 }
1342}
1343
1344struct FittedGbcPipelineAdapter<F: Float + Send + Sync + 'static>(
1346 FittedGradientBoostingClassifier<F>,
1347);
1348
1349impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> FittedPipelineEstimator<F>
1350 for FittedGbcPipelineAdapter<F>
1351{
1352 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1353 let preds = self.0.predict(x)?;
1354 Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
1355 }
1356}
1357
1358fn sigmoid<F: Float>(x: F) -> F {
1364 F::one() / (F::one() + (-x).exp())
1365}
1366
1367fn softmax_matrix<F: Float>(
1371 f_vals: &[Array1<F>],
1372 n_samples: usize,
1373 n_classes: usize,
1374) -> Vec<Vec<F>> {
1375 let mut probs: Vec<Vec<F>> = vec![vec![F::zero(); n_samples]; n_classes];
1376
1377 for i in 0..n_samples {
1378 let max_val = (0..n_classes)
1380 .map(|k| f_vals[k][i])
1381 .fold(F::neg_infinity(), |a, b| if b > a { b } else { a });
1382
1383 let mut sum = F::zero();
1384 let mut exps = vec![F::zero(); n_classes];
1385 for k in 0..n_classes {
1386 exps[k] = (f_vals[k][i] - max_val).exp();
1387 sum = sum + exps[k];
1388 }
1389
1390 let eps = F::from(1e-15).unwrap();
1391 if sum < eps {
1392 sum = eps;
1393 }
1394
1395 for k in 0..n_classes {
1396 probs[k][i] = exps[k] / sum;
1397 }
1398 }
1399
1400 probs
1401}
1402
1403fn median_f<F: Float>(arr: &Array1<F>) -> F {
1405 let mut sorted: Vec<F> = arr.iter().copied().collect();
1406 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1407 let n = sorted.len();
1408 if n == 0 {
1409 return F::zero();
1410 }
1411 if n % 2 == 1 {
1412 sorted[n / 2]
1413 } else {
1414 (sorted[n / 2 - 1] + sorted[n / 2]) / F::from(2.0).unwrap()
1415 }
1416}
1417
1418fn quantile_f<F: Float>(vals: &[F], alpha: f64) -> F {
1420 if vals.is_empty() {
1421 return F::zero();
1422 }
1423 let mut sorted: Vec<F> = vals.to_vec();
1424 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1425 let idx = ((sorted.len() as f64 - 1.0) * alpha).round() as usize;
1426 let idx = idx.min(sorted.len() - 1);
1427 sorted[idx]
1428}
1429
1430fn compute_regression_residuals<F: Float>(
1432 y: &Array1<F>,
1433 f_vals: &Array1<F>,
1434 loss: RegressionLoss,
1435 huber_alpha: f64,
1436) -> Array1<F> {
1437 let n = y.len();
1438 match loss {
1439 RegressionLoss::LeastSquares => {
1440 let mut residuals = Array1::zeros(n);
1442 for i in 0..n {
1443 residuals[i] = y[i] - f_vals[i];
1444 }
1445 residuals
1446 }
1447 RegressionLoss::Lad => {
1448 let mut residuals = Array1::zeros(n);
1457 for i in 0..n {
1458 residuals[i] = if y[i] >= f_vals[i] {
1459 F::one()
1460 } else {
1461 -F::one()
1462 };
1463 }
1464 residuals
1465 }
1466 RegressionLoss::Huber => {
1467 let raw_residuals: Vec<F> = (0..n).map(|i| (y[i] - f_vals[i]).abs()).collect();
1469 let delta = quantile_f(&raw_residuals, huber_alpha);
1470
1471 let mut residuals = Array1::zeros(n);
1472 for i in 0..n {
1473 let diff = y[i] - f_vals[i];
1474 if diff.abs() <= delta {
1475 residuals[i] = diff;
1476 } else if diff > F::zero() {
1477 residuals[i] = delta;
1478 } else {
1479 residuals[i] = -delta;
1480 }
1481 }
1482 residuals
1483 }
1484 }
1485}
1486
1487fn f_from<F: Float>(v: f64) -> F {
1503 F::from(v).unwrap_or_else(F::zero)
1504}
1505
1506fn group_samples_by_leaf<F: Float>(
1518 tree: &[Node<F>],
1519 x: &Array2<F>,
1520 sample_indices: &[usize],
1521) -> std::collections::HashMap<usize, Vec<usize>> {
1522 let mut groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
1523 for &i in sample_indices {
1524 let row = x.row(i);
1525 let leaf_idx = decision_tree::traverse(tree, &row);
1526 groups.entry(leaf_idx).or_default().push(i);
1527 }
1528 groups
1529}
1530
1531fn lad_leaf_value<F: Float>(y: &Array1<F>, f_vals: &Array1<F>, idx: &[usize]) -> F {
1544 let diffs: Vec<F> = idx.iter().map(|&i| y[i] - f_vals[i]).collect();
1545 weighted_percentile_uniform(&diffs, 50.0)
1546}
1547
1548fn weighted_percentile_uniform<F: Float>(vals: &[F], percentile: f64) -> F {
1560 let n = vals.len();
1561 if n == 0 {
1562 return F::zero();
1563 }
1564 let mut sorted: Vec<F> = vals.to_vec();
1565 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1566 let total = n as f64;
1567 let mut adjusted = percentile / 100.0 * total;
1568 if adjusted == 0.0 {
1569 adjusted = f64::MIN_POSITIVE;
1572 }
1573 let mut idx = n - 1;
1575 for i in 0..n {
1576 if (i + 1) as f64 >= adjusted {
1577 idx = i;
1578 break;
1579 }
1580 }
1581 sorted[idx]
1582}
1583
1584fn huber_stage_delta<F: Float>(
1588 y: &Array1<F>,
1589 f_vals: &Array1<F>,
1590 sample_indices: &[usize],
1591 huber_alpha: f64,
1592) -> F {
1593 let abserr: Vec<F> = sample_indices
1594 .iter()
1595 .map(|&i| (y[i] - f_vals[i]).abs())
1596 .collect();
1597 weighted_percentile_uniform(&abserr, 100.0 * huber_alpha)
1598}
1599
1600fn huber_leaf_value<F: Float>(y: &Array1<F>, f_vals: &Array1<F>, idx: &[usize], delta: F) -> F {
1612 let n = idx.len();
1613 if n == 0 {
1614 return F::zero();
1615 }
1616 let diffs: Vec<F> = idx.iter().map(|&i| y[i] - f_vals[i]).collect();
1617 let median = weighted_percentile_uniform(&diffs, 50.0);
1618 let mut term_sum = F::zero();
1619 for &d in &diffs {
1620 let resid = d - median;
1621 let sign = if resid > F::zero() {
1622 F::one()
1623 } else if resid < F::zero() {
1624 -F::one()
1625 } else {
1626 F::zero()
1627 };
1628 let clipped = delta.min(resid.abs());
1629 term_sum = term_sum + sign * clipped;
1630 }
1631 median + term_sum / f_from(n as f64)
1632}
1633
1634fn binary_newton_leaf<F: Float>(residuals: &Array1<F>, probs: &[F], idx: &[usize]) -> F {
1643 let n = idx.len();
1644 if n == 0 {
1645 return F::zero();
1646 }
1647 let nf = f_from::<F>(n as f64);
1648 let mut num = F::zero();
1649 let mut den = F::zero();
1650 for &i in idx {
1651 let p = probs[i];
1652 num = num + residuals[i];
1653 den = den + p * (F::one() - p);
1654 }
1655 safe_divide(num / nf, den / nf)
1656}
1657
1658fn multiclass_newton_leaf<F: Float>(
1666 residuals: &Array1<F>,
1667 probs_k: &[F],
1668 idx: &[usize],
1669 n_classes: usize,
1670) -> F {
1671 let n = idx.len();
1672 if n == 0 {
1673 return F::zero();
1674 }
1675 let nf = f_from::<F>(n as f64);
1676 let mut num = F::zero();
1677 let mut den = F::zero();
1678 for &i in idx {
1679 let p = probs_k[i];
1680 num = num + residuals[i];
1681 den = den + p * (F::one() - p);
1682 }
1683 let k_factor = f_from::<F>((n_classes - 1) as f64 / n_classes as f64);
1684 safe_divide((num / nf) * k_factor, den / nf)
1685}
1686
1687fn safe_divide<F: Float>(numerator: F, denominator: F) -> F {
1694 let threshold = f_from::<F>(1e-150);
1695 if denominator.abs() < threshold {
1696 F::zero()
1697 } else {
1698 numerator / denominator
1699 }
1700}
1701
1702#[cfg(test)]
1707mod tests {
1708 use super::*;
1709 use approx::assert_relative_eq;
1710 use ndarray::array;
1711
1712 #[test]
1715 fn test_gbr_simple_least_squares() {
1716 let x =
1717 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1718 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1719
1720 let model = GradientBoostingRegressor::<f64>::new()
1721 .with_n_estimators(50)
1722 .with_learning_rate(0.1)
1723 .with_random_state(42);
1724 let fitted = model.fit(&x, &y).unwrap();
1725 let preds = fitted.predict(&x).unwrap();
1726
1727 assert_eq!(preds.len(), 8);
1728 for i in 0..4 {
1729 assert!(preds[i] < 3.0, "Expected ~1.0, got {}", preds[i]);
1730 }
1731 for i in 4..8 {
1732 assert!(preds[i] > 3.0, "Expected ~5.0, got {}", preds[i]);
1733 }
1734 }
1735
1736 #[test]
1737 fn test_gbr_lad_loss() {
1738 let x =
1739 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1740 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1741
1742 let model = GradientBoostingRegressor::<f64>::new()
1743 .with_n_estimators(50)
1744 .with_loss(RegressionLoss::Lad)
1745 .with_random_state(42);
1746 let fitted = model.fit(&x, &y).unwrap();
1747 let preds = fitted.predict(&x).unwrap();
1748
1749 assert_eq!(preds.len(), 8);
1750 for i in 0..4 {
1752 assert!(preds[i] < 3.5, "LAD expected <3.5, got {}", preds[i]);
1753 }
1754 for i in 4..8 {
1755 assert!(preds[i] > 2.5, "LAD expected >2.5, got {}", preds[i]);
1756 }
1757 }
1758
1759 #[test]
1760 fn test_gbr_huber_loss() {
1761 let x =
1762 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1763 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1764
1765 let model = GradientBoostingRegressor::<f64>::new()
1766 .with_n_estimators(50)
1767 .with_loss(RegressionLoss::Huber)
1768 .with_huber_alpha(0.9)
1769 .with_random_state(42);
1770 let fitted = model.fit(&x, &y).unwrap();
1771 let preds = fitted.predict(&x).unwrap();
1772
1773 assert_eq!(preds.len(), 8);
1774 }
1775
1776 #[test]
1777 fn test_gbr_reproducibility() {
1778 let x =
1779 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1780 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1781
1782 let model = GradientBoostingRegressor::<f64>::new()
1783 .with_n_estimators(20)
1784 .with_random_state(123);
1785
1786 let fitted1 = model.fit(&x, &y).unwrap();
1787 let fitted2 = model.fit(&x, &y).unwrap();
1788
1789 let preds1 = fitted1.predict(&x).unwrap();
1790 let preds2 = fitted2.predict(&x).unwrap();
1791
1792 for (p1, p2) in preds1.iter().zip(preds2.iter()) {
1793 assert_relative_eq!(*p1, *p2, epsilon = 1e-10);
1794 }
1795 }
1796
1797 #[test]
1798 fn test_gbr_feature_importances() {
1799 let x = Array2::from_shape_vec(
1800 (10, 3),
1801 vec![
1802 1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 3.0, 0.0, 0.0, 4.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0,
1803 0.0, 0.0, 7.0, 0.0, 0.0, 8.0, 0.0, 0.0, 9.0, 0.0, 0.0, 10.0, 0.0, 0.0,
1804 ],
1805 )
1806 .unwrap();
1807 let y = array![1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0];
1808
1809 let model = GradientBoostingRegressor::<f64>::new()
1810 .with_n_estimators(20)
1811 .with_random_state(42);
1812 let fitted = model.fit(&x, &y).unwrap();
1813 let importances = fitted.feature_importances();
1814
1815 assert_eq!(importances.len(), 3);
1816 assert!(importances[0] > importances[1]);
1818 assert!(importances[0] > importances[2]);
1819 }
1820
1821 #[test]
1822 fn test_gbr_shape_mismatch_fit() {
1823 let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1824 let y = array![1.0, 2.0];
1825
1826 let model = GradientBoostingRegressor::<f64>::new().with_n_estimators(5);
1827 assert!(model.fit(&x, &y).is_err());
1828 }
1829
1830 #[test]
1831 fn test_gbr_shape_mismatch_predict() {
1832 let x =
1833 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1834 let y = array![1.0, 2.0, 3.0, 4.0];
1835
1836 let model = GradientBoostingRegressor::<f64>::new()
1837 .with_n_estimators(5)
1838 .with_random_state(0);
1839 let fitted = model.fit(&x, &y).unwrap();
1840
1841 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1842 assert!(fitted.predict(&x_bad).is_err());
1843 }
1844
1845 #[test]
1846 fn test_gbr_empty_data() {
1847 let x = Array2::<f64>::zeros((0, 2));
1848 let y = Array1::<f64>::zeros(0);
1849
1850 let model = GradientBoostingRegressor::<f64>::new().with_n_estimators(5);
1851 assert!(model.fit(&x, &y).is_err());
1852 }
1853
1854 #[test]
1855 fn test_gbr_zero_estimators() {
1856 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1857 let y = array![1.0, 2.0, 3.0, 4.0];
1858
1859 let model = GradientBoostingRegressor::<f64>::new().with_n_estimators(0);
1860 assert!(model.fit(&x, &y).is_err());
1861 }
1862
1863 #[test]
1864 fn test_gbr_invalid_learning_rate() {
1865 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1866 let y = array![1.0, 2.0, 3.0, 4.0];
1867
1868 let model = GradientBoostingRegressor::<f64>::new()
1869 .with_n_estimators(5)
1870 .with_learning_rate(0.0);
1871 assert!(model.fit(&x, &y).is_err());
1872 }
1873
1874 #[test]
1875 fn test_gbr_invalid_subsample() {
1876 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1877 let y = array![1.0, 2.0, 3.0, 4.0];
1878
1879 let model = GradientBoostingRegressor::<f64>::new()
1880 .with_n_estimators(5)
1881 .with_subsample(0.0);
1882 assert!(model.fit(&x, &y).is_err());
1883
1884 let model2 = GradientBoostingRegressor::<f64>::new()
1885 .with_n_estimators(5)
1886 .with_subsample(1.5);
1887 assert!(model2.fit(&x, &y).is_err());
1888 }
1889
1890 #[test]
1891 fn test_gbr_subsample() {
1892 let x =
1893 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1894 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1895
1896 let model = GradientBoostingRegressor::<f64>::new()
1897 .with_n_estimators(50)
1898 .with_subsample(0.5)
1899 .with_random_state(42);
1900 let fitted = model.fit(&x, &y).unwrap();
1901 let preds = fitted.predict(&x).unwrap();
1902
1903 assert_eq!(preds.len(), 8);
1904 }
1905
1906 #[test]
1907 fn test_gbr_pipeline_integration() {
1908 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1909 let y = array![1.0, 2.0, 3.0, 4.0];
1910
1911 let model = GradientBoostingRegressor::<f64>::new()
1912 .with_n_estimators(10)
1913 .with_random_state(42);
1914 let fitted = model.fit_pipeline(&x, &y).unwrap();
1915 let preds = fitted.predict_pipeline(&x).unwrap();
1916 assert_eq!(preds.len(), 4);
1917 }
1918
1919 #[test]
1920 fn test_gbr_f32_support() {
1921 let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
1922 let y = Array1::from_vec(vec![1.0f32, 2.0, 3.0, 4.0]);
1923
1924 let model = GradientBoostingRegressor::<f32>::new()
1925 .with_n_estimators(10)
1926 .with_random_state(42);
1927 let fitted = model.fit(&x, &y).unwrap();
1928 let preds = fitted.predict(&x).unwrap();
1929 assert_eq!(preds.len(), 4);
1930 }
1931
1932 #[test]
1933 fn test_gbr_max_depth() {
1934 let x =
1935 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1936 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1937
1938 let model = GradientBoostingRegressor::<f64>::new()
1939 .with_n_estimators(20)
1940 .with_max_depth(Some(1))
1941 .with_random_state(42);
1942 let fitted = model.fit(&x, &y).unwrap();
1943 let preds = fitted.predict(&x).unwrap();
1944 assert_eq!(preds.len(), 8);
1945 }
1946
1947 #[test]
1948 fn test_gbr_default_trait() {
1949 let model = GradientBoostingRegressor::<f64>::default();
1950 assert_eq!(model.n_estimators, 100);
1951 assert!((model.learning_rate - 0.1).abs() < 1e-10);
1952 }
1953
1954 #[test]
1957 fn test_gbc_binary_simple() {
1958 let x = Array2::from_shape_vec(
1959 (8, 2),
1960 vec![
1961 1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
1962 ],
1963 )
1964 .unwrap();
1965 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1966
1967 let model = GradientBoostingClassifier::<f64>::new()
1968 .with_n_estimators(50)
1969 .with_learning_rate(0.1)
1970 .with_random_state(42);
1971 let fitted = model.fit(&x, &y).unwrap();
1972 let preds = fitted.predict(&x).unwrap();
1973
1974 assert_eq!(preds.len(), 8);
1975 for i in 0..4 {
1976 assert_eq!(preds[i], 0, "Expected 0 at index {}, got {}", i, preds[i]);
1977 }
1978 for i in 4..8 {
1979 assert_eq!(preds[i], 1, "Expected 1 at index {}, got {}", i, preds[i]);
1980 }
1981 }
1982
1983 #[test]
1984 fn test_gbc_multiclass() {
1985 let x = Array2::from_shape_vec((9, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
1986 .unwrap();
1987 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1988
1989 let model = GradientBoostingClassifier::<f64>::new()
1990 .with_n_estimators(50)
1991 .with_learning_rate(0.1)
1992 .with_random_state(42);
1993 let fitted = model.fit(&x, &y).unwrap();
1994 let preds = fitted.predict(&x).unwrap();
1995
1996 assert_eq!(preds.len(), 9);
1997 let correct = preds.iter().zip(y.iter()).filter(|(p, t)| p == t).count();
1999 assert!(
2000 correct >= 6,
2001 "Expected at least 6/9 correct, got {correct}/9"
2002 );
2003 }
2004
2005 #[test]
2006 fn test_gbc_has_classes() {
2007 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2008 let y = array![0, 1, 2, 0, 1, 2];
2009
2010 let model = GradientBoostingClassifier::<f64>::new()
2011 .with_n_estimators(5)
2012 .with_random_state(0);
2013 let fitted = model.fit(&x, &y).unwrap();
2014
2015 assert_eq!(fitted.classes(), &[0, 1, 2]);
2016 assert_eq!(fitted.n_classes(), 3);
2017 }
2018
2019 #[test]
2020 fn test_gbc_reproducibility() {
2021 let x = Array2::from_shape_vec(
2022 (8, 2),
2023 vec![
2024 1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
2025 ],
2026 )
2027 .unwrap();
2028 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
2029
2030 let model = GradientBoostingClassifier::<f64>::new()
2031 .with_n_estimators(10)
2032 .with_random_state(42);
2033
2034 let fitted1 = model.fit(&x, &y).unwrap();
2035 let fitted2 = model.fit(&x, &y).unwrap();
2036
2037 let preds1 = fitted1.predict(&x).unwrap();
2038 let preds2 = fitted2.predict(&x).unwrap();
2039 assert_eq!(preds1, preds2);
2040 }
2041
2042 #[test]
2043 fn test_gbc_feature_importances() {
2044 let x = Array2::from_shape_vec(
2045 (10, 3),
2046 vec![
2047 1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 3.0, 0.0, 0.0, 4.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0,
2048 0.0, 0.0, 7.0, 0.0, 0.0, 8.0, 0.0, 0.0, 9.0, 0.0, 0.0, 10.0, 0.0, 0.0,
2049 ],
2050 )
2051 .unwrap();
2052 let y = array![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
2053
2054 let model = GradientBoostingClassifier::<f64>::new()
2055 .with_n_estimators(20)
2056 .with_random_state(42);
2057 let fitted = model.fit(&x, &y).unwrap();
2058 let importances = fitted.feature_importances();
2059
2060 assert_eq!(importances.len(), 3);
2061 assert!(importances[0] > importances[1]);
2062 assert!(importances[0] > importances[2]);
2063 }
2064
2065 #[test]
2066 fn test_gbc_shape_mismatch_fit() {
2067 let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2068 let y = array![0, 1];
2069
2070 let model = GradientBoostingClassifier::<f64>::new().with_n_estimators(5);
2071 assert!(model.fit(&x, &y).is_err());
2072 }
2073
2074 #[test]
2075 fn test_gbc_shape_mismatch_predict() {
2076 let x =
2077 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
2078 let y = array![0, 0, 1, 1];
2079
2080 let model = GradientBoostingClassifier::<f64>::new()
2081 .with_n_estimators(5)
2082 .with_random_state(0);
2083 let fitted = model.fit(&x, &y).unwrap();
2084
2085 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2086 assert!(fitted.predict(&x_bad).is_err());
2087 }
2088
2089 #[test]
2090 fn test_gbc_empty_data() {
2091 let x = Array2::<f64>::zeros((0, 2));
2092 let y = Array1::<usize>::zeros(0);
2093
2094 let model = GradientBoostingClassifier::<f64>::new().with_n_estimators(5);
2095 assert!(model.fit(&x, &y).is_err());
2096 }
2097
2098 #[test]
2099 fn test_gbc_single_class() {
2100 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2101 let y = array![0, 0, 0];
2102
2103 let model = GradientBoostingClassifier::<f64>::new().with_n_estimators(5);
2104 assert!(model.fit(&x, &y).is_err());
2105 }
2106
2107 #[test]
2108 fn test_gbc_zero_estimators() {
2109 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2110 let y = array![0, 0, 1, 1];
2111
2112 let model = GradientBoostingClassifier::<f64>::new().with_n_estimators(0);
2113 assert!(model.fit(&x, &y).is_err());
2114 }
2115
2116 #[test]
2117 fn test_gbc_pipeline_integration() {
2118 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2119 let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
2120
2121 let model = GradientBoostingClassifier::<f64>::new()
2122 .with_n_estimators(10)
2123 .with_random_state(42);
2124 let fitted = model.fit_pipeline(&x, &y).unwrap();
2125 let preds = fitted.predict_pipeline(&x).unwrap();
2126 assert_eq!(preds.len(), 6);
2127 }
2128
2129 #[test]
2130 fn test_gbc_f32_support() {
2131 let x = Array2::from_shape_vec((6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2132 let y = array![0, 0, 0, 1, 1, 1];
2133
2134 let model = GradientBoostingClassifier::<f32>::new()
2135 .with_n_estimators(10)
2136 .with_random_state(42);
2137 let fitted = model.fit(&x, &y).unwrap();
2138 let preds = fitted.predict(&x).unwrap();
2139 assert_eq!(preds.len(), 6);
2140 }
2141
2142 #[test]
2143 fn test_gbc_subsample() {
2144 let x = Array2::from_shape_vec(
2145 (8, 2),
2146 vec![
2147 1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
2148 ],
2149 )
2150 .unwrap();
2151 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
2152
2153 let model = GradientBoostingClassifier::<f64>::new()
2154 .with_n_estimators(20)
2155 .with_subsample(0.5)
2156 .with_random_state(42);
2157 let fitted = model.fit(&x, &y).unwrap();
2158 let preds = fitted.predict(&x).unwrap();
2159 assert_eq!(preds.len(), 8);
2160 }
2161
2162 #[test]
2163 fn test_gbc_default_trait() {
2164 let model = GradientBoostingClassifier::<f64>::default();
2165 assert_eq!(model.n_estimators, 100);
2166 assert!((model.learning_rate - 0.1).abs() < 1e-10);
2167 }
2168
2169 #[test]
2170 fn test_gbc_non_contiguous_labels() {
2171 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
2172 let y = array![10, 10, 10, 20, 20, 20];
2173
2174 let model = GradientBoostingClassifier::<f64>::new()
2175 .with_n_estimators(20)
2176 .with_random_state(42);
2177 let fitted = model.fit(&x, &y).unwrap();
2178 let preds = fitted.predict(&x).unwrap();
2179
2180 assert_eq!(preds.len(), 6);
2181 for &p in &preds {
2182 assert!(p == 10 || p == 20);
2183 }
2184 }
2185
2186 #[test]
2189 fn test_sigmoid() {
2190 assert_relative_eq!(sigmoid(0.0f64), 0.5, epsilon = 1e-10);
2191 assert!(sigmoid(10.0f64) > 0.999);
2192 assert!(sigmoid(-10.0f64) < 0.001);
2193 }
2194
2195 #[test]
2196 fn test_median_f_odd() {
2197 let arr = array![3.0, 1.0, 2.0];
2198 assert_relative_eq!(median_f(&arr), 2.0, epsilon = 1e-10);
2199 }
2200
2201 #[test]
2202 fn test_median_f_even() {
2203 let arr = array![4.0, 1.0, 3.0, 2.0];
2204 assert_relative_eq!(median_f(&arr), 2.5, epsilon = 1e-10);
2205 }
2206
2207 #[test]
2208 fn test_median_f_empty() {
2209 let arr = Array1::<f64>::zeros(0);
2210 assert_relative_eq!(median_f(&arr), 0.0, epsilon = 1e-10);
2211 }
2212
2213 #[test]
2214 fn test_quantile_f() {
2215 let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2216 let q90 = quantile_f(&vals, 0.9);
2217 assert!((4.0..=5.0).contains(&q90));
2218 }
2219
2220 #[test]
2221 fn test_regression_residuals_least_squares() {
2222 let y = array![1.0, 2.0, 3.0];
2223 let f = array![0.5, 2.5, 2.0];
2224 let r = compute_regression_residuals(&y, &f, RegressionLoss::LeastSquares, 0.9);
2225 assert_relative_eq!(r[0], 0.5, epsilon = 1e-10);
2226 assert_relative_eq!(r[1], -0.5, epsilon = 1e-10);
2227 assert_relative_eq!(r[2], 1.0, epsilon = 1e-10);
2228 }
2229
2230 #[test]
2231 fn test_regression_residuals_lad() {
2232 let y = array![1.0, 2.0, 3.0];
2237 let f = array![0.5, 2.5, 3.0];
2238 let r = compute_regression_residuals(&y, &f, RegressionLoss::Lad, 0.9);
2239 assert_relative_eq!(r[0], 1.0, epsilon = 1e-10); assert_relative_eq!(r[1], -1.0, epsilon = 1e-10); assert_relative_eq!(r[2], 1.0, epsilon = 1e-10); }
2243
2244 #[test]
2247 fn test_group_samples_by_leaf() {
2248 let x = array![[1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0], [8.0]];
2251 let residuals = array![-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
2252 let params = decision_tree::TreeParams {
2253 max_depth: Some(1),
2254 min_samples_split: 2,
2255 min_samples_leaf: 1,
2256 };
2257 let idx: Vec<usize> = (0..8).collect();
2258 let tree = build_regression_tree_with_feature_subset(&x, &residuals, &idx, &[0], ¶ms);
2259 let groups = group_samples_by_leaf(&tree, &x, &idx);
2260 let total: usize = groups.values().map(std::vec::Vec::len).sum();
2263 assert_eq!(total, 8);
2264 assert_eq!(groups.len(), 2);
2265 let mut sizes: Vec<usize> = groups.values().map(std::vec::Vec::len).collect();
2266 sizes.sort_unstable();
2267 assert_eq!(sizes, vec![3, 5]);
2268 }
2269
2270 #[test]
2271 fn test_lad_leaf_value_median() {
2272 let y = array![0.0, 0.0, 0.0, 10.0, 1.0, 1.0, 1.0, 20.0];
2278 let f = Array1::from_elem(8, 1.0);
2279 let leaf = vec![3usize, 4, 5, 6, 7];
2280 assert_relative_eq!(lad_leaf_value(&y, &f, &leaf), 0.0, epsilon = 1e-12);
2281 let leaf_l = vec![0usize, 1, 2];
2283 assert_relative_eq!(lad_leaf_value(&y, &f, &leaf_l), -1.0, epsilon = 1e-12);
2284 }
2285
2286 #[test]
2287 fn test_binary_newton_leaf_value() {
2288 let residuals = array![0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5];
2295 let probs = vec![0.5f64; 8];
2296 let leaf = vec![0usize, 1, 2, 3];
2297 assert_relative_eq!(
2298 binary_newton_leaf(&residuals, &probs, &leaf),
2299 2.0,
2300 epsilon = 1e-12
2301 );
2302 let probs_deg = vec![1.0f64; 8];
2305 assert_relative_eq!(
2306 binary_newton_leaf(&residuals, &probs_deg, &leaf),
2307 0.0,
2308 epsilon = 1e-12
2309 );
2310 }
2311
2312 #[test]
2313 fn test_least_squares_leaf_identity() {
2314 let x = array![[1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0], [8.0]];
2320 let y = array![0.0, 0.0, 0.0, 10.0, 1.0, 1.0, 1.0, 20.0];
2321 let residuals = array![-1.0, -1.0, -1.0, 9.0, 0.0, 0.0, 0.0, 19.0];
2322 let params = decision_tree::TreeParams {
2323 max_depth: Some(1),
2324 min_samples_split: 2,
2325 min_samples_leaf: 1,
2326 };
2327 let idx: Vec<usize> = (0..8).collect();
2328 let tree = build_regression_tree_with_feature_subset(&x, &residuals, &idx, &[0], ¶ms);
2329 let groups = group_samples_by_leaf(&tree, &x, &idx);
2330 let f0 = Array1::from_elem(8, 0.0);
2331 for samples in groups.values() {
2332 if samples.len() == 5 {
2333 let lad = lad_leaf_value(&y, &f0, samples);
2334 let mean: f64 =
2335 samples.iter().map(|&i| residuals[i]).sum::<f64>() / samples.len() as f64;
2336 assert_relative_eq!(mean, 5.6, epsilon = 1e-12);
2337 assert!((lad - mean).abs() > 1.0, "median must differ from mean");
2338 }
2339 }
2340 }
2341
2342 #[test]
2343 fn test_regression_residuals_huber() {
2344 let y = array![1.0, 2.0, 10.0, 3.0, 4.0];
2345 let f = array![1.5, 2.5, 2.0, 3.5, 4.5];
2346 let r = compute_regression_residuals(&y, &f, RegressionLoss::Huber, 0.9);
2350 assert_relative_eq!(r[0], -0.5, epsilon = 1e-10);
2352 assert_relative_eq!(r[1], -0.5, epsilon = 1e-10);
2353 assert_relative_eq!(r[2], 8.0, epsilon = 1e-10);
2354 assert_relative_eq!(r[3], -0.5, epsilon = 1e-10);
2355 assert_relative_eq!(r[4], -0.5, epsilon = 1e-10);
2356
2357 let r2 = compute_regression_residuals(&y, &f, RegressionLoss::Huber, 0.1);
2361 assert_relative_eq!(r2[0], -0.5, epsilon = 1e-10);
2362 assert_relative_eq!(r2[2], 0.5, epsilon = 1e-10);
2364 }
2365
2366 #[test]
2367 fn test_gbc_multiclass_4_classes() {
2368 let x = Array2::from_shape_vec(
2369 (12, 1),
2370 vec![
2371 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
2372 ],
2373 )
2374 .unwrap();
2375 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3];
2376
2377 let model = GradientBoostingClassifier::<f64>::new()
2378 .with_n_estimators(50)
2379 .with_random_state(42);
2380 let fitted = model.fit(&x, &y).unwrap();
2381 let preds = fitted.predict(&x).unwrap();
2382
2383 assert_eq!(preds.len(), 12);
2384 assert_eq!(fitted.n_classes(), 4);
2385 }
2386
2387 #[test]
2388 fn test_gbc_invalid_learning_rate() {
2389 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2390 let y = array![0, 0, 1, 1];
2391
2392 let model = GradientBoostingClassifier::<f64>::new()
2393 .with_n_estimators(5)
2394 .with_learning_rate(-0.1);
2395 assert!(model.fit(&x, &y).is_err());
2396 }
2397}