Skip to main content

ferrolearn_tree/
gradient_boosting.rs

1//! Gradient boosting classifiers and regressors.
2//!
3//! This module provides [`GradientBoostingClassifier`] and [`GradientBoostingRegressor`],
4//! which build ensembles of decision trees sequentially. Each tree fits the negative
5//! gradient (pseudo-residuals) of the loss function, progressively reducing prediction error.
6//!
7//! # Regression Losses
8//!
9//! - **`LeastSquares`** (L2): mean squared error; pseudo-residuals are `y - F(x)`.
10//! - **`Lad`** (L1): least absolute deviation; pseudo-residuals are `sign(y - F(x))`.
11//! - **`Huber`**: a blend of L2 (for small residuals) and L1 (for large residuals),
12//!   controlled by the `alpha` quantile parameter (default 0.9).
13//!
14//! # Classification Loss
15//!
16//! - **`LogLoss`**: binary and multiclass logistic loss. For binary classification a
17//!   single model is trained on log-odds; for *K*-class problems *K* trees are built
18//!   per boosting round (one-vs-rest in probability space via softmax).
19//!
20//! # Examples
21//!
22//! ```
23//! use ferrolearn_tree::GradientBoostingRegressor;
24//! use ferrolearn_core::{Fit, Predict};
25//! use ndarray::{array, Array1, Array2};
26//!
27//! let x = Array2::from_shape_vec((8, 1), vec![
28//!     1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0,
29//! ]).unwrap();
30//! let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
31//!
32//! let model = GradientBoostingRegressor::<f64>::new()
33//!     .with_n_estimators(50)
34//!     .with_learning_rate(0.1)
35//!     .with_random_state(42);
36//! let fitted = model.fit(&x, &y).unwrap();
37//! let preds = fitted.predict(&x).unwrap();
38//! assert_eq!(preds.len(), 8);
39//! ```
40//!
41//! ## REQ status
42//!
43//! Mirrors `sklearn.ensemble.GradientBoostingClassifier` /
44//! `GradientBoostingRegressor` (`sklearn/ensemble/_gb.py` + `sklearn/_loss`).
45//! See `.design/tree/gradient_boosting.md`. Non-test consumers: crate
46//! re-export + `RsGradientBoostingRegressor`/`RsGradientBoostingClassifier`
47//! PyO3 bindings (`ferrolearn-python/src/extras.rs`).
48//!
49//! **Determinism:** at the default `subsample=1.0` the fit is fully
50//! deterministic and end-to-end-comparable to sklearn; `subsample<1.0` draws
51//! `StdRng` vs numpy MT19937 — a documented stochastic-GB boundary (#743).
52//!
53//! | REQ | Description | Status |
54//! |-----|-------------|--------|
55//! | REQ-1 | Param defaults: `n_estimators=100`, `learning_rate=0.1`, `max_depth=3`, `subsample=1.0` | SHIPPED |
56//! | REQ-2 | Negative-gradient pseudo-residuals per loss (L2/LAD/Huber/LogLoss; LAD tie `+1 if y>=F`) | SHIPPED |
57//! | REQ-3 | Init prior: mean (L2) / median (LAD,Huber) / log-odds (binary) (multiclass raw `ln(K)` offset = #742) | SHIPPED |
58//! | REQ-4 | `GradientBoostingRegressor(squared_error)` end-to-end parity (subsample=1.0) | SHIPPED |
59//! | REQ-5 | LAD terminal region = `_weighted_percentile(y-F, 50)` per leaf (`_gb.py:241-247`, `loss.py:565-574`) | SHIPPED |
60//! | REQ-6 | Huber terminal region = median + clipped-mean, stage `delta` percentile (`loss.py:694-710`, `_gb.py:267-272`) | SHIPPED |
61//! | REQ-7 | LogLoss Newton terminal region: binary `Σ(y-p)/Σp(1-p)`, multiclass `(K-1)/K·Σr/Σp(1-p)` (`_gb.py:191-225`) | SHIPPED |
62//! | REQ-8 | `friedman_mse` criterion + feature_importances (trees use mse — same splits; importance may differ) | NOT-STARTED (#740) |
63//! | REQ-8b | decision_tree exact-MSE-tie split-feature choice (→ multiclass GBC predict_proba drift) | NOT-STARTED (#739) |
64//! | REQ-9 | `subsample<1.0` numpy-parity (stochastic GB) | NOT-STARTED (#743, RNG boundary) |
65//! | REQ-10 | Early stopping (`n_iter_no_change`/`validation_fraction`/`tol`) + `ccp_alpha`/`max_features`/`min_impurity_decrease`/`init`/`staged_predict` | NOT-STARTED (#741) |
66//! | REQ-11 | PyO3 binding fidelity — RsGradientBoosting{Regressor,Classifier} thin (no loss/subsample/predict_proba/feature_importances_/classes_) | NOT-STARTED (#759) |
67//! | REQ-12 | ferray substrate migration | NOT-STARTED (#744) |
68//! | REQ-13 | Reject non-finite input (NaN+Inf): `fn reject_non_finite` at the top of BOTH `GradientBoostingRegressor::fit` (+ float-`y` finite check) and `GradientBoostingClassifier::fit` rejects NaN AND infinity. sklearn validates X/y up front (`_gb.py:659-661`, default `force_all_finite=True`) BEFORE any base learner ⇒ `ValueError`, even though the ferrolearn `DecisionTree` base now accepts NaN (#2277). Consumers: the existing `fit` entries (crate-root re-export + `RsGradientBoosting{Regressor,Classifier}` PyO3 reg). Pinned by `divergence_gradient_boosting_regressor_nan_not_rejected`/`divergence_gradient_boosting_classifier_nan_not_rejected` (live sklearn 1.5.2 raises). | SHIPPED |
69
70use 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
84/// Reject `X` containing any non-finite value (NaN or infinity).
85///
86/// sklearn's `BaseGradientBoosting.fit` validates X (and y) up front via
87/// `_validate_data(...)` with the default `force_all_finite=True`
88/// (`sklearn/ensemble/_gb.py:659-661`), raising
89/// `ValueError("Input X contains NaN.")` (`validation.py:147-154`) BEFORE any
90/// base learner is built — so although ferrolearn's `DecisionTree` base now
91/// accepts NaN (#2277), GradientBoosting rejects it at its own entry, matching
92/// sklearn. NaN AND infinity are both rejected. Never panics (R-CODE-2).
93fn 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// ---------------------------------------------------------------------------
104// Regression loss enum
105// ---------------------------------------------------------------------------
106
107/// Loss function for gradient boosting regression.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum RegressionLoss {
110    /// Least squares (L2) loss.
111    LeastSquares,
112    /// Least absolute deviation (L1) loss.
113    Lad,
114    /// Huber loss: L2 for small residuals, L1 for large residuals.
115    Huber,
116}
117
118/// Loss function for gradient boosting classification.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum ClassificationLoss {
121    /// Log-loss (logistic / cross-entropy) for binary and multiclass.
122    LogLoss,
123}
124
125// ---------------------------------------------------------------------------
126// GradientBoostingRegressor
127// ---------------------------------------------------------------------------
128
129/// Gradient boosting regressor.
130///
131/// Builds an additive model in a forward stage-wise fashion, fitting each
132/// regression tree to the negative gradient of the loss function evaluated
133/// on the current ensemble prediction.
134///
135/// # Type Parameters
136///
137/// - `F`: The floating-point type (`f32` or `f64`).
138#[derive(Debug, Clone)]
139pub struct GradientBoostingRegressor<F> {
140    /// Number of boosting stages (trees).
141    pub n_estimators: usize,
142    /// Learning rate (shrinkage) applied to each tree's contribution.
143    pub learning_rate: f64,
144    /// Maximum depth of each tree.
145    pub max_depth: Option<usize>,
146    /// Minimum number of samples required to split an internal node.
147    pub min_samples_split: usize,
148    /// Minimum number of samples required in a leaf node.
149    pub min_samples_leaf: usize,
150    /// Fraction of samples to use for fitting each tree (stochastic boosting).
151    pub subsample: f64,
152    /// Loss function.
153    pub loss: RegressionLoss,
154    /// Alpha quantile for Huber loss (only used when `loss == Huber`).
155    pub huber_alpha: f64,
156    /// Random seed for reproducibility.
157    pub random_state: Option<u64>,
158    _marker: std::marker::PhantomData<F>,
159}
160
161impl<F: Float> GradientBoostingRegressor<F> {
162    /// Create a new `GradientBoostingRegressor` with default settings.
163    ///
164    /// Defaults: `n_estimators = 100`, `learning_rate = 0.1`,
165    /// `max_depth = Some(3)`, `min_samples_split = 2`,
166    /// `min_samples_leaf = 1`, `subsample = 1.0`,
167    /// `loss = LeastSquares`, `huber_alpha = 0.9`.
168    #[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    /// Set the number of boosting stages.
185    #[must_use]
186    pub fn with_n_estimators(mut self, n: usize) -> Self {
187        self.n_estimators = n;
188        self
189    }
190
191    /// Set the learning rate (shrinkage).
192    #[must_use]
193    pub fn with_learning_rate(mut self, lr: f64) -> Self {
194        self.learning_rate = lr;
195        self
196    }
197
198    /// Set the maximum tree depth.
199    #[must_use]
200    pub fn with_max_depth(mut self, d: Option<usize>) -> Self {
201        self.max_depth = d;
202        self
203    }
204
205    /// Set the minimum number of samples to split a node.
206    #[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    /// Set the minimum number of samples in a leaf.
213    #[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    /// Set the subsample ratio (fraction of training data per tree).
220    #[must_use]
221    pub fn with_subsample(mut self, ratio: f64) -> Self {
222        self.subsample = ratio;
223        self
224    }
225
226    /// Set the loss function.
227    #[must_use]
228    pub fn with_loss(mut self, loss: RegressionLoss) -> Self {
229        self.loss = loss;
230        self
231    }
232
233    /// Set the alpha quantile for Huber loss.
234    #[must_use]
235    pub fn with_huber_alpha(mut self, alpha: f64) -> Self {
236        self.huber_alpha = alpha;
237        self
238    }
239
240    /// Set the random seed for reproducibility.
241    #[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// ---------------------------------------------------------------------------
255// FittedGradientBoostingRegressor
256// ---------------------------------------------------------------------------
257
258/// A fitted gradient boosting regressor.
259///
260/// Stores the initial prediction (intercept) and the sequence of fitted trees.
261/// Predictions are computed as `init + learning_rate * sum(tree_predictions)`.
262#[derive(Debug, Clone)]
263pub struct FittedGradientBoostingRegressor<F> {
264    /// Initial prediction (mean of training targets for L2 loss, median for L1/Huber).
265    init: F,
266    /// Learning rate used during training.
267    learning_rate: F,
268    /// Sequence of fitted trees (one per boosting round).
269    trees: Vec<Vec<Node<F>>>,
270    /// Number of features.
271    n_features: usize,
272    /// Per-feature importance scores (normalised).
273    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    /// Fit the gradient boosting regressor.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
285    /// numbers of samples.
286    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
287    /// Returns [`FerroError::InvalidParameter`] for invalid hyperparameters.
288    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 (and the float target y) up front, before building
328        // any base learner, matching sklearn (`_gb.py:659-661`).
329        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        // `learning_rate` is `>0.0` (checked above) ⇒ representable; the
338        // fallback keeps this conversion panic-free (R-CODE-2).
339        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        // Initial prediction.
347        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        // Current predictions for each sample.
356        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            // Compute pseudo-residuals (negative gradient).
374            let residuals = compute_regression_residuals(y, &f_vals, self.loss, self.huber_alpha);
375
376            // Subsample indices.
377            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            // Build a regression tree on the pseudo-residuals.
384            let mut tree = build_regression_tree_with_feature_subset(
385                x,
386                &residuals,
387                &sample_indices,
388                &all_features,
389                &params,
390            );
391
392            // Terminal-region (line-search) leaf update over the in-bag leaf
393            // samples (`_update_terminal_regions`, `_gb.py:129-264`). L2 is the
394            // identity (`:155-157`/:186) — leave the mean-residual leaf untouched
395            // so the REQ-4 linchpin stays exact. Lad/Huber replace each leaf with
396            // the loss-optimal value before `f_vals += lr*leaf`.
397            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            // Update predictions with the (possibly replaced) leaf values.
421            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        // Compute feature importances across all trees.
433        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    /// Returns the initial prediction (intercept) of the boosted model.
458    #[must_use]
459    pub fn init(&self) -> F {
460        self.init
461    }
462
463    /// Returns the learning rate used during training.
464    #[must_use]
465    pub fn learning_rate(&self) -> F {
466        self.learning_rate
467    }
468
469    /// Returns a reference to the sequence of fitted trees.
470    #[must_use]
471    pub fn trees(&self) -> &[Vec<Node<F>>] {
472        &self.trees
473    }
474
475    /// Returns the number of features the model was trained on.
476    #[must_use]
477    pub fn n_features(&self) -> usize {
478        self.n_features
479    }
480
481    /// R² coefficient of determination on the given test data.
482    /// Equivalent to sklearn's `RegressorMixin.score`.
483    ///
484    /// # Errors
485    ///
486    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
487    /// the feature count does not match the training data.
488    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    /// Predict target values.
506    ///
507    /// # Errors
508    ///
509    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
510    /// not match the fitted model.
511    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
545// Pipeline integration.
546impl<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// ---------------------------------------------------------------------------
566// GradientBoostingClassifier
567// ---------------------------------------------------------------------------
568
569/// Gradient boosting classifier.
570///
571/// For binary classification a single model is trained on log-odds residuals.
572/// For multiclass (*K* classes), *K* regression trees are built per boosting
573/// round (one-vs-rest in probability space via softmax).
574///
575/// # Type Parameters
576///
577/// - `F`: The floating-point type (`f32` or `f64`).
578#[derive(Debug, Clone)]
579pub struct GradientBoostingClassifier<F> {
580    /// Number of boosting stages.
581    pub n_estimators: usize,
582    /// Learning rate (shrinkage).
583    pub learning_rate: f64,
584    /// Maximum depth of each tree.
585    pub max_depth: Option<usize>,
586    /// Minimum number of samples required to split an internal node.
587    pub min_samples_split: usize,
588    /// Minimum number of samples required in a leaf node.
589    pub min_samples_leaf: usize,
590    /// Fraction of samples to use for fitting each tree.
591    pub subsample: f64,
592    /// Classification loss function.
593    pub loss: ClassificationLoss,
594    /// Random seed for reproducibility.
595    pub random_state: Option<u64>,
596    _marker: std::marker::PhantomData<F>,
597}
598
599impl<F: Float> GradientBoostingClassifier<F> {
600    /// Create a new `GradientBoostingClassifier` with default settings.
601    ///
602    /// Defaults: `n_estimators = 100`, `learning_rate = 0.1`,
603    /// `max_depth = Some(3)`, `min_samples_split = 2`,
604    /// `min_samples_leaf = 1`, `subsample = 1.0`,
605    /// `loss = LogLoss`.
606    #[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    /// Set the number of boosting stages.
622    #[must_use]
623    pub fn with_n_estimators(mut self, n: usize) -> Self {
624        self.n_estimators = n;
625        self
626    }
627
628    /// Set the learning rate (shrinkage).
629    #[must_use]
630    pub fn with_learning_rate(mut self, lr: f64) -> Self {
631        self.learning_rate = lr;
632        self
633    }
634
635    /// Set the maximum tree depth.
636    #[must_use]
637    pub fn with_max_depth(mut self, d: Option<usize>) -> Self {
638        self.max_depth = d;
639        self
640    }
641
642    /// Set the minimum number of samples to split a node.
643    #[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    /// Set the minimum number of samples in a leaf.
650    #[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    /// Set the subsample ratio.
657    #[must_use]
658    pub fn with_subsample(mut self, ratio: f64) -> Self {
659        self.subsample = ratio;
660        self
661    }
662
663    /// Set the random seed for reproducibility.
664    #[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// ---------------------------------------------------------------------------
678// FittedGradientBoostingClassifier
679// ---------------------------------------------------------------------------
680
681/// A fitted gradient boosting classifier.
682///
683/// For binary classification, stores a single sequence of trees predicting log-odds.
684/// For multiclass, stores `K` sequences of trees (one per class).
685#[derive(Debug, Clone)]
686pub struct FittedGradientBoostingClassifier<F> {
687    /// Sorted unique class labels.
688    classes: Vec<usize>,
689    /// Initial predictions per class (log-odds or log-prior).
690    init: Vec<F>,
691    /// Learning rate.
692    learning_rate: F,
693    /// Trees: for binary, `trees[0]` has all trees. For multiclass,
694    /// `trees[k]` has trees for class k.
695    trees: Vec<Vec<Vec<Node<F>>>>,
696    /// Number of features.
697    n_features: usize,
698    /// Per-feature importance scores (normalised).
699    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    /// Fit the gradient boosting classifier.
709    ///
710    /// # Errors
711    ///
712    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
713    /// numbers of samples.
714    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
715    /// Returns [`FerroError::InvalidParameter`] for invalid hyperparameters.
716    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 up front, before building any base learner,
756        // matching sklearn (`_gb.py:659-661`).
757        reject_non_finite(x)?;
758
759        // Determine unique classes.
760        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            // Binary classification: single model on log-odds.
798            self.fit_binary(
799                x,
800                &y_mapped,
801                n_samples,
802                n_features,
803                &classes,
804                lr,
805                &params,
806                &all_features,
807                subsample_size,
808                &mut rng,
809            )
810        } else {
811            // Multiclass: K trees per round.
812            self.fit_multiclass(
813                x,
814                &y_mapped,
815                n_samples,
816                n_features,
817                n_classes,
818                &classes,
819                lr,
820                &params,
821                &all_features,
822                subsample_size,
823                &mut rng,
824            )
825        }
826    }
827}
828
829impl<F: Float + Send + Sync + 'static> GradientBoostingClassifier<F> {
830    /// Fit binary classification (log-loss on log-odds).
831    #[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        // Count positive class proportion for initial log-odds.
846        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            // Compute probabilities from current log-odds.
857            let probs: Vec<F> = f_vals.iter().map(|&fv| sigmoid(fv)).collect();
858
859            // Pseudo-residuals: y - p.
860            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            // Subsample.
867            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            // Build tree on residuals.
874            let mut tree = build_regression_tree_with_feature_subset(
875                x,
876                &residuals,
877                &sample_indices,
878                all_features,
879                params,
880            );
881
882            // Terminal-region Newton-step leaf update (`HalfBinomialLoss` branch,
883            // `_gb.py:191-206`): replace each leaf with `Σ(y-p) / Σ p(1-p)` over
884            // its in-bag samples (`p = sigmoid(raw) = probs[i]`), then add lr*leaf.
885            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            // Update f_vals with the replaced leaf values.
894            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        // Feature importances.
906        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    /// Fit multiclass classification (K trees per round, softmax).
930    #[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        // Initial log-prior for each class.
946        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        // f_vals[k][i] = current raw score for class k, sample i.
961        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            // Compute softmax probabilities.
972            let probs = softmax_matrix(&f_vals, n_samples, n_classes);
973
974            // Subsample.
975            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 each class, compute residuals and fit a tree.
982            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                // Terminal-region Newton-step leaf update (`HalfMultinomialLoss`
1002                // branch, `_gb.py:208-225`): replace each leaf with
1003                // `(K-1)/K · Σ neg_g / Σ p(1-p)` over its in-bag samples
1004                // (`p = probs[k][i]`), then add lr*leaf.
1005                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                // Update f_vals for class k with the replaced leaf values.
1014                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        // Feature importances aggregated across all classes and rounds.
1027        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    /// Returns the initial predictions per class (log-odds or log-prior).
1055    #[must_use]
1056    pub fn init(&self) -> &[F] {
1057        &self.init
1058    }
1059
1060    /// Returns the learning rate used during training.
1061    #[must_use]
1062    pub fn learning_rate(&self) -> F {
1063        self.learning_rate
1064    }
1065
1066    /// Returns a reference to the tree ensemble.
1067    ///
1068    /// For binary classification, `trees()[0]` contains all trees.
1069    /// For multiclass, `trees()[k]` contains trees for class `k`.
1070    #[must_use]
1071    pub fn trees(&self) -> &[Vec<Vec<Node<F>>>] {
1072        &self.trees
1073    }
1074
1075    /// Returns the number of features the model was trained on.
1076    #[must_use]
1077    pub fn n_features(&self) -> usize {
1078        self.n_features
1079    }
1080
1081    /// Mean accuracy on the given test data and labels.
1082    /// Equivalent to sklearn's `ClassifierMixin.score`.
1083    ///
1084    /// # Errors
1085    ///
1086    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
1087    /// the feature count does not match the training data.
1088    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    /// Predict class probabilities. Mirrors sklearn's
1101    /// `GradientBoostingClassifier.predict_proba`.
1102    ///
1103    /// Binary: applies the logistic link to the cumulative log-odds.
1104    /// Multiclass: softmax over K cumulative scores.
1105    ///
1106    /// Returns shape `(n_samples, n_classes)`; rows sum to 1.
1107    ///
1108    /// # Errors
1109    ///
1110    /// Returns [`FerroError::ShapeMismatch`] if the number of features
1111    /// does not match the fitted model.
1112    #[allow(clippy::needless_range_loop)] // index-by-class loop is natural for the per-class score accumulation
1113    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    /// Element-wise log of [`predict_proba`](Self::predict_proba). Mirrors
1175    /// sklearn's `ClassifierMixin.predict_log_proba`.
1176    ///
1177    /// # Errors
1178    ///
1179    /// Forwards any error from [`predict_proba`](Self::predict_proba).
1180    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    /// Cumulative raw scores per sample (pre-link). Mirrors sklearn's
1186    /// `GradientBoostingClassifier.decision_function`.
1187    ///
1188    /// Binary: shape `(n_samples, 1)` containing the cumulative log-odds.
1189    /// Multiclass: shape `(n_samples, n_classes)` containing per-class
1190    /// cumulative scores. (sklearn returns shape `(n_samples,)` for the
1191    /// binary case; ferrolearn keeps a 2-D shape for type-uniformity.)
1192    ///
1193    /// # Errors
1194    ///
1195    /// Returns [`FerroError::ShapeMismatch`] if the number of features
1196    /// does not match the fitted model.
1197    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    /// Predict class labels.
1248    ///
1249    /// # Errors
1250    ///
1251    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
1252    /// not match the fitted model.
1253    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            // Binary: single log-odds model.
1267            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            // Multiclass: K models, argmax of softmax.
1285            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
1329// Pipeline integration.
1330impl<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
1344/// Pipeline adapter for `FittedGradientBoostingClassifier<F>`.
1345struct 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
1358// ---------------------------------------------------------------------------
1359// Internal helpers
1360// ---------------------------------------------------------------------------
1361
1362/// Sigmoid function: 1 / (1 + exp(-x)).
1363fn sigmoid<F: Float>(x: F) -> F {
1364    F::one() / (F::one() + (-x).exp())
1365}
1366
1367/// Compute softmax probabilities for each class across all samples.
1368///
1369/// Returns `probs[k][i]` = probability of class k for sample i.
1370fn 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        // Find max for numerical stability.
1379        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
1403/// Compute the median of an Array1.
1404fn 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
1418/// Compute the quantile of a slice at level `alpha` (0..1).
1419fn 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
1430/// Compute pseudo-residuals (negative gradient) for regression losses.
1431fn 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            // negative gradient of 0.5*(y - f)^2 is (y - f)
1441            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            // Negative gradient of |y - f|. scikit-learn's `CyAbsoluteError`
1449            // gradient (`sklearn/_loss/_loss.pyx`, exposed via
1450            // `AbsoluteError.gradient`) uses the tie-break `gradient = -1 if
1451            // y >= raw else +1`, so the NEGATIVE gradient is `+1 if y >= raw else
1452            // -1` — a sample with a ZERO residual (`y == f`) contributes `+1`, NOT
1453            // `0`. Matching this is load-bearing: the tie must not introduce a
1454            // spurious within-leaf split (R-DEV-1; live-verified, see
1455            // `test_regression_residuals_lad`).
1456            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            // Compute residuals and delta from quantile.
1468            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
1487// ---------------------------------------------------------------------------
1488// Terminal-region (leaf-value) line-search updates
1489// ---------------------------------------------------------------------------
1490//
1491// After fitting a regression tree to the negative gradient, scikit-learn's
1492// `_update_terminal_regions` (`sklearn/ensemble/_gb.py:129-264`) REPLACES each
1493// leaf's value with the loss-optimal line-search value
1494// (`argmin_x loss(y, raw_old + x*value)`, `:149-151`) computed over the in-bag
1495// samples that fall in that leaf, THEN applies `raw += learning_rate * leaf`
1496// (`:262-264`). `HalfSquaredError`'s update is the IDENTITY (`:155-157`/`:186`),
1497// so only Lad/Huber/LogLoss need the replacement.
1498
1499/// Convert an `f64` constant to `F` without an `.unwrap()` (R-CODE-2): every
1500/// constant used here (`2.0`, `n`, `(K-1)/K`, `1e-150`) is finite, so `F::from`
1501/// succeeds; the `F::zero()` fallback can only fire on an unreachable `None`.
1502fn f_from<F: Float>(v: f64) -> F {
1503    F::from(v).unwrap_or_else(F::zero)
1504}
1505
1506/// Group the in-bag `sample_indices` by the flat-`Vec<Node>` leaf index they
1507/// traverse to.
1508///
1509/// Mirrors the leaf bucketing in `_update_terminal_regions`
1510/// (`sklearn/ensemble/_gb.py:184` `terminal_regions = tree.apply(X)`, masked to
1511/// the in-bag `sample_mask` at `:188-189`). At `subsample == 1.0`,
1512/// `sample_indices` is `0..n_samples` (all samples); for `subsample < 1.0` it is
1513/// the subsampled in-bag set, matching sklearn's mask.
1514///
1515/// Returns `(leaf_idx -> Vec<sample_idx>)` keyed by the leaf's position in the
1516/// flat tree.
1517fn 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
1531/// Loss-optimal leaf value for `AbsoluteError` (LAD): the median of the leaf's
1532/// residuals `diff = y[idx] - f_vals[idx]`.
1533///
1534/// Mirrors `_update_terminal_regions` generic `else`
1535/// (`sklearn/ensemble/_gb.py:241-247`) →
1536/// `AbsoluteError.fit_intercept_only` (`sklearn/_loss/loss.py:565-574`). Because
1537/// `fit` always passes `sample_weight = _check_sample_weight(None, X) = np.ones`
1538/// (never `None`, `_gb.py:255`), the loss takes the
1539/// `_weighted_percentile(y_true, sample_weight, 50)` branch — the LOWER weighted
1540/// percentile (`sklearn/utils/stats.py:53-68`), NOT `np.median`. For an even
1541/// count this is a single sorted element (the lower-middle), never the average of
1542/// the two middles. sklearn 1.5.2 has no `_averaged_weighted_percentile`.
1543fn 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
1548/// Lower weighted percentile with uniform weights, matching
1549/// `sklearn.utils.stats._weighted_percentile` (`sklearn/utils/stats.py:6`) used
1550/// by `set_huber_delta` (`sklearn/ensemble/_gb.py:267-272`).
1551///
1552/// Sorts `vals`, takes the cumulative (uniform) weight CDF, and returns the value
1553/// at the first sorted index whose CDF reaches `percentile/100 * total_weight`
1554/// (`np.searchsorted`, left side). With uniform weights of 1, `total = n`,
1555/// `target = percentile/100 * n`, and the index is the first `i` with
1556/// `i + 1 >= target` clipped to `n-1` — the LOWER percentile sklearn computes.
1557/// (`percentile == 0` is special-cased to skip leading zero-weight observations;
1558/// with all-ones weights the nudged target still lands on index 0.)
1559fn 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        // GH20528: nudge off exactly zero so the search skips leading
1570        // zero-weight observations; with uniform weights this stays at index 0.
1571        adjusted = f64::MIN_POSITIVE;
1572    }
1573    // weight_cdf[i] = i + 1; searchsorted (left) finds first i with i+1 >= target.
1574    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
1584/// Per-stage Huber `delta`, computed ONCE per stage over the in-bag samples like
1585/// sklearn's `set_huber_delta` (`sklearn/ensemble/_gb.py:267-272`):
1586/// `_weighted_percentile(|y - raw|, sample_weight, 100 * quantile)`.
1587fn 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
1600/// Loss-optimal leaf value for `HuberLoss`: `median(diff) + average(sign(diff -
1601/// median) * min(delta, |diff - median|))` over the leaf's residuals
1602/// `diff = y[idx] - f_vals[idx]`.
1603///
1604/// Mirrors `HuberLoss.fit_intercept_only` (`sklearn/_loss/loss.py:694-710`) with
1605/// `delta` from [`huber_stage_delta`]. The `median` term is
1606/// `_weighted_percentile(y_true, sample_weight, 50)` — the LOWER weighted
1607/// percentile — because `fit` always passes `sample_weight = np.ones` (never
1608/// `None`, `_gb.py:255`); for an even count this is a single sorted element, NOT
1609/// `np.median`'s average of the two middles. Unweighted (`subsample == 1.0`):
1610/// `np.average` is the arithmetic mean.
1611fn 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
1634/// Loss-optimal leaf value for `HalfBinomialLoss` — the single Newton-Raphson
1635/// step `average(neg_g) / average(p(1-p))` over the leaf's samples, with
1636/// `p = y - neg_g = sigmoid(raw)`.
1637///
1638/// Mirrors the `HalfBinomialLoss` branch of `_update_terminal_regions`
1639/// (`sklearn/ensemble/_gb.py:191-206`): `numerator = average(neg_g)`,
1640/// `denominator = average(prob*(1-prob))`, `_safe_divide(num, den)` returning
1641/// `0.0` when `|den| < 1e-150` (`:66-78`). `neg_g[i] = residual[i] = y[i] - p[i]`.
1642fn 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
1658/// Loss-optimal leaf value for `HalfMultinomialLoss` (class-`k` tree) — the
1659/// Newton step `(K-1)/K * average(neg_g) / average(p(1-p))` over the leaf's
1660/// samples, with `p` the softmax probability of class `k`.
1661///
1662/// Mirrors the `HalfMultinomialLoss` branch of `_update_terminal_regions`
1663/// (`sklearn/ensemble/_gb.py:208-225`): `numerator = average(neg_g) * (K-1)/K`,
1664/// `denominator = average(prob*(1-prob))`, `_safe_divide(num, den)`.
1665fn 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
1687/// Division guarding a near-zero (or exactly zero) Hessian denominator, returning
1688/// `0.0` when `|denominator| < 1e-150`.
1689///
1690/// Mirrors `_safe_divide` (`sklearn/ensemble/_gb.py:66-78`): a zero Hessian
1691/// (`proba == 0` or `1` exactly) means no loss improvement, so the leaf value is
1692/// set to zero.
1693fn 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// ---------------------------------------------------------------------------
1703// Tests
1704// ---------------------------------------------------------------------------
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709    use approx::assert_relative_eq;
1710    use ndarray::array;
1711
1712    // -- Regressor tests --
1713
1714    #[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        // LAD should still separate the two groups.
1751        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        // First feature should be most important.
1817        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    // -- Classifier tests --
1955
1956    #[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        // At least training data should mostly be correct.
1998        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    // -- Helper tests --
2187
2188    #[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        // sklearn's `AbsoluteError.gradient` tie convention is `gradient = -1 if
2233        // y >= raw else +1`, so the NEGATIVE gradient is `+1 if y >= raw else -1`.
2234        // The zero-residual sample (`y == f`) yields `+1`, NOT `0` (live-probed:
2235        // `AbsoluteError().gradient(y=[1.], raw=[1.]) == [-1.0]`, neg == +1.0).
2236        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); // y>f -> +1
2240        assert_relative_eq!(r[1], -1.0, epsilon = 1e-10); // y<f -> -1
2241        assert_relative_eq!(r[2], 1.0, epsilon = 1e-10); // y==f tie -> +1 (sklearn)
2242    }
2243
2244    // -- Terminal-region (leaf-value) line-search updates (REQ-5/6/7) --
2245
2246    #[test]
2247    fn test_group_samples_by_leaf() {
2248        // Build a depth-1 tree on a clean step target so the split lands at 3.5,
2249        // bucketing samples {0,1,2} into the left leaf and {3,..,7} into the right.
2250        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], &params);
2259        let groups = group_samples_by_leaf(&tree, &x, &idx);
2260        // Every sample is grouped under exactly one leaf; the partition is {0,1,2}
2261        // and {3,4,5,6,7} (split at 3.5).
2262        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        // sklearn replaces the leaf with `np.median(y[idx] - raw[idx])`
2273        // (`AbsoluteError.fit_intercept_only`, `_loss/loss.py:565-574`). For the
2274        // skewed leaf {3,4,5,6,7} of the divergence fixture (y=[10,1,1,1,20],
2275        // raw=1.0) the residuals are [9,0,0,0,19], whose median is 0.0 — the
2276        // exact `value=0.0` sklearn assigns (live-verified tree dump, stage 0).
2277        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        // The left leaf {0,1,2}: residuals [-1,-1,-1], median -1.0 (sklearn value).
2282        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        // sklearn's `HalfBinomialLoss` leaf = `Σ(y-p) / Σ p(1-p)` over the leaf
2289        // (`_gb.py:191-206`; with uniform weights `np.average` = mean, the n
2290        // cancels). Hand-computed: residuals = y - p with p = 0.5 (init log-odds
2291        // 0 -> sigmoid 0.5). For a leaf {0,1,2,3} of class-1 samples
2292        // (y_mapped=1), residual = 1 - 0.5 = 0.5 each; p(1-p) = 0.25 each.
2293        // leaf = (4*0.5) / (4*0.25) = 2.0 / 1.0 = 2.0.
2294        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        // Zero-Hessian guard: a leaf with p == 1 exactly gives denominator 0 ->
2303        // sklearn `_safe_divide` returns 0.0 (`_gb.py:66-78`).
2304        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        // The L2 terminal-region update is the IDENTITY (`_gb.py:155-157`/:186):
2315        // the GBR fit loop must NOT touch the mean-residual leaf for
2316        // `LeastSquares`. Confirm the built leaf for the skewed right group keeps
2317        // the residual MEAN (5.6), which differs sharply from the LAD median (0.0)
2318        // — proving the L2 path keeps the mean, not a median/Newton value.
2319        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], &params);
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        // abs residuals: [0.5, 0.5, 8.0, 0.5, 0.5]
2347        // alpha=0.9 quantile index = round(4 * 0.9) = 4 => sorted[4] = 8.0
2348        // So delta = 8.0, meaning all residuals are within delta and treated as L2.
2349        let r = compute_regression_residuals(&y, &f, RegressionLoss::Huber, 0.9);
2350        // All residuals should be y - f.
2351        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        // Test with lower alpha to trigger clipping.
2358        // alpha=0.1, quantile idx = round(4*0.1) = 0 => sorted[0] = 0.5
2359        // delta = 0.5, so the 8.0 residual is clipped.
2360        let r2 = compute_regression_residuals(&y, &f, RegressionLoss::Huber, 0.1);
2361        assert_relative_eq!(r2[0], -0.5, epsilon = 1e-10);
2362        // Third residual: diff=8.0 > delta=0.5, so clipped to delta=0.5.
2363        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}