Skip to main content

ferrolearn_bayes/
base.rs

1//! Shared naive-Bayes base — `_BaseNB` / `_BaseDiscreteNB` analogs.
2//!
3//! This module mirrors scikit-learn's abstract naive-Bayes class hierarchy
4//! (`sklearn/naive_bayes.py`): the abstract `_BaseNB` (the prediction pipeline
5//! shared by every NB variant) and the `_BaseDiscreteNB` smoothing helper
6//! `_check_alpha`. ferrolearn expresses the `_BaseNB` contract as the
7//! [`BaseNB`] trait whose provided methods implement the exact prediction
8//! pipeline — argmax over the joint log-likelihood, the `jll - logsumexp(jll)`
9//! log-probability normalization, and `exp(...)` for probabilities — leaving
10//! only `joint_log_likelihood` (sklearn's abstract `_joint_log_likelihood`)
11//! and `nb_classes` (the `classes_` attribute) for each variant to provide.
12//!
13//! The five Naive Bayes variants ([`crate::GaussianNB`],
14//! [`crate::MultinomialNB`], [`crate::BernoulliNB`], [`crate::ComplementNB`],
15//! [`crate::CategoricalNB`]) implement [`BaseNB`] for their fitted types and
16//! delegate their inherent `predict_proba` / `predict_log_proba` /
17//! `predict_joint_log_proba` methods and their [`Predict`](ferrolearn_core::Predict)
18//! impls to the trait defaults. They are the non-test production consumers of
19//! this base (R-DEFER-1).
20//!
21//! # `## REQ status`
22//!
23//! Binary classification (R-DEFER-2): SHIPPED needs impl + a non-test
24//! production consumer + green verification. The non-test consumers are the
25//! five `Fitted*NB` types whose predict pipeline delegates here; the green
26//! verification is the existing in-tree variant test suite (91 lib tests),
27//! which exercises the delegated pipeline unchanged, plus the live sklearn
28//! oracle sanity-check below. Cites use symbol anchors (ferrolearn) /
29//! `file:line` (sklearn 1.5.2, commit 156ef14). Live oracle = installed
30//! sklearn 1.5.2.
31//!
32//! | REQ | Status | Evidence |
33//! |---|---|---|
34//! | REQ-1 (`_BaseNB.predict` — `classes_[argmax(jll)]`) | SHIPPED | provided method `BaseNB::nb_predict` (per-row argmax over `joint_log_likelihood`, first-max/smallest-index tie-break) mirrors `_BaseNB.predict` (`sklearn/naive_bayes.py:103`, `self.classes_[np.argmax(jll, axis=1)]`). Non-test consumers: `impl Predict for FittedGaussianNB`/`FittedMultinomialNB`/`FittedBernoulliNB`/`FittedComplementNB`/`FittedCategoricalNB`'s `fn predict` delegate to `BaseNB::nb_predict`. Verified: live oracle `MultinomialNB().fit([[1,2],[0,3],[4,0],[3,1]],[0,0,1,1]).predict([[2,2]])` → `[0]`; ferrolearn matches; the 91 in-tree variant tests stay green. |
35//! | REQ-2 (`_BaseNB.predict_log_proba` — `jll - logsumexp(jll)`) | SHIPPED | provided method `BaseNB::nb_predict_log_proba` (calls `crate::log_softmax_rows`) mirrors `_BaseNB.predict_log_proba` (`sklearn/naive_bayes.py:123-126`, `jll - np.atleast_2d(logsumexp(jll, axis=1)).T`). Non-test consumers: each variant's `pub fn predict_log_proba` delegates here. Verified: live oracle predict_log_proba `[[2,2]]` → `[[-0.5470675457484475, -0.8642776061017265]]`; ferrolearn matches to ~1e-12. |
36//! | REQ-3 (`_BaseNB.predict_proba` — `exp(predict_log_proba)`) | SHIPPED | provided method `BaseNB::nb_predict_proba` (`exp` of `nb_predict_log_proba`) mirrors `_BaseNB.predict_proba` (`sklearn/naive_bayes.py:144`, `np.exp(self.predict_log_proba(X))`). Non-test consumers: each variant's `pub fn predict_proba` (the rows-sum-to-1 softmax) — value-identical to `exp(jll - logsumexp)`. Verified: live oracle predict_proba `[[2,2]]` → `[[0.5786441724102462, 0.4213558275897536]]`; ferrolearn matches; `*_predict_proba_sums_to_one` tests stay green. |
37//! | REQ-4 (`_BaseDiscreteNB._check_alpha` — floor 1e-10 unless `force_alpha`) | SHIPPED | `check_alpha` (re-homed from `lib.rs::clamp_alpha`) mirrors `_BaseDiscreteNB._check_alpha` (`sklearn/naive_bayes.py:604-626`: `alpha_lower_bound = 1e-10`; `np.maximum(alpha, alpha_lower_bound)` when `alpha_min < alpha_lower_bound and not self.force_alpha`). Non-test consumers: `MultinomialNB`/`BernoulliNB`/`ComplementNB`/`CategoricalNB` `fn fit` call `crate::clamp_alpha` (re-exported `pub(crate) use base::check_alpha as clamp_alpha`). Verified: `*_alpha_smoothing_effect` and `*_default` tests stay green. |
38//! | REQ-5 (`_BaseDiscreteNB.coef_` / `intercept_`) | NOT-STARTED | open prereq blocker. sklearn exposes `coef_ = feature_log_prob_[1:]` (binary collapses to one row) and `intercept_ = class_log_prior_[1:]` (`sklearn/naive_bayes.py` `_BaseDiscreteNB.coef_`/`intercept_` properties). No ferrolearn analog: the discrete variants store `log_theta`/`log_prob`/`weights` and `log_prior` but expose no `coef_`/`intercept_` accessor on the fitted types or the trait. |
39//! | REQ-6 (`_BaseDiscreteNB.partial_fit` / `_count` / `_update_feature_log_prob`) | NOT-STARTED | open prereq blocker. sklearn factors fitting through abstract `_count`/`_update_feature_log_prob` driven by a shared `partial_fit` (`sklearn/naive_bayes.py:628-709`). ferrolearn implements `partial_fit` per variant (each `Fitted*NB::partial_fit`) with no shared `_count`/`_update_feature_log_prob` seam on this base; this base covers only the predict pipeline + `_check_alpha`. |
40//! | REQ-7 (`_BaseDiscreteNB._update_class_log_prior` / `class_prior` handling) | NOT-STARTED | open prereq blocker. sklearn's `_update_class_log_prior` (`sklearn/naive_bayes.py:580-602`) centralizes empirical-vs-uniform-vs-explicit prior selection driven by `fit_prior`/`class_prior`. ferrolearn duplicates this logic per discrete variant (`fit`/`partial_fit` prior blocks); it is not lifted onto this base. |
41
42use ferrolearn_core::error::FerroError;
43use ndarray::{Array1, Array2};
44use num_traits::Float;
45
46/// Smoothing-floor mirroring scikit-learn `_BaseDiscreteNB._check_alpha`
47/// (`sklearn/naive_bayes.py:604-626`).
48///
49/// When `force_alpha = false` and `alpha < 1e-10`, the alpha is raised to the
50/// `1e-10` lower bound (sklearn's legacy "alpha too small will result in
51/// numeric errors" guard, `np.maximum(alpha, alpha_lower_bound)`). When
52/// `force_alpha = true`, the user-supplied alpha is returned unchanged, even
53/// if zero.
54///
55/// This is the re-homed `clamp_alpha`; `lib.rs` re-exports it as `clamp_alpha`
56/// so the discrete variants' call sites are unchanged.
57#[must_use]
58pub(crate) fn check_alpha<F: Float>(alpha: F, force_alpha: bool) -> F {
59    if force_alpha {
60        alpha
61    } else {
62        let floor = F::from(1e-10).unwrap_or_else(F::epsilon);
63        if alpha < floor { floor } else { alpha }
64    }
65}
66
67/// Shared naive-Bayes prediction pipeline — the `_BaseNB` analog.
68///
69/// Mirrors scikit-learn's abstract `_BaseNB` (`sklearn/naive_bayes.py`). An
70/// implementor supplies the two abstract pieces — [`joint_log_likelihood`]
71/// (sklearn's abstract `_joint_log_likelihood`, the unnormalized
72/// `log P(c) + log P(x|c)`) and [`nb_classes`] (the sorted `classes_`) — and
73/// gets the full prediction pipeline for free:
74///
75/// - [`nb_predict`] — `classes_[argmax(jll)]` (`sklearn/naive_bayes.py:103`),
76/// - [`nb_predict_log_proba`] — `jll - logsumexp(jll)`
77///   (`sklearn/naive_bayes.py:123-126`),
78/// - [`nb_predict_proba`] — `exp(predict_log_proba)`
79///   (`sklearn/naive_bayes.py:144`),
80/// - [`nb_predict_joint_log_proba`] — the unnormalized joint log-probability
81///   (`sklearn/naive_bayes.py:62-84`).
82///
83/// [`joint_log_likelihood`]: BaseNB::joint_log_likelihood
84/// [`nb_classes`]: BaseNB::nb_classes
85/// [`nb_predict`]: BaseNB::nb_predict
86/// [`nb_predict_log_proba`]: BaseNB::nb_predict_log_proba
87/// [`nb_predict_proba`]: BaseNB::nb_predict_proba
88/// [`nb_predict_joint_log_proba`]: BaseNB::nb_predict_joint_log_proba
89pub trait BaseNB<F: Float> {
90    /// Compute the unnormalized joint log-likelihood `log P(c) + log P(x|c)`.
91    ///
92    /// Abstract — mirrors sklearn `_BaseNB._joint_log_likelihood`
93    /// (`sklearn/naive_bayes.py:42-53`). Returns shape
94    /// `(n_samples, n_classes)`; column `c` corresponds to the class in
95    /// [`nb_classes`](BaseNB::nb_classes) at index `c`.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`FerroError::ShapeMismatch`](ferrolearn_core::error::FerroError)
100    /// if the feature count of `x` does not match the fitted model.
101    fn joint_log_likelihood(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError>;
102
103    /// The sorted class labels — the `classes_` attribute.
104    fn nb_classes(&self) -> &[usize];
105
106    /// Predict class labels: `classes_[argmax(jll, axis=1)]`.
107    ///
108    /// Mirrors sklearn `_BaseNB.predict` (`sklearn/naive_bayes.py:86-103`,
109    /// `return self.classes_[np.argmax(jll, axis=1)]`). The argmax uses
110    /// `np.argmax`'s **first-max** rule — on ties the smallest column index
111    /// wins, and because `classes_` is sorted that is the smallest class
112    /// label. The scan replaces the running best only on a strict `>`,
113    /// reproducing first-max exactly.
114    ///
115    /// # Errors
116    ///
117    /// Propagates any error from [`joint_log_likelihood`](BaseNB::joint_log_likelihood).
118    fn nb_predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
119        let scores = self.joint_log_likelihood(x)?;
120        let classes = self.nb_classes();
121        let n_samples = scores.nrows();
122        let n_classes = scores.ncols();
123
124        let mut predictions = Array1::<usize>::zeros(n_samples);
125        for i in 0..n_samples {
126            // First-max argmax (np.argmax): start at column 0, replace only on
127            // a strict greater-than so ties keep the earlier (smaller) index.
128            let best_class = (1..n_classes).fold(0usize, |best, ci| {
129                match scores[[i, ci]].partial_cmp(&scores[[i, best]]) {
130                    Some(core::cmp::Ordering::Greater) => ci,
131                    _ => best,
132                }
133            });
134            predictions[i] = classes[best_class];
135        }
136        Ok(predictions)
137    }
138
139    /// Return log-probability estimates: `jll - logsumexp(jll, axis=1)`.
140    ///
141    /// Mirrors sklearn `_BaseNB.predict_log_proba`
142    /// (`sklearn/naive_bayes.py:105-126`). Computed via
143    /// `crate::log_softmax_rows`, the numerically stable row-wise
144    /// log-softmax, so the result is bit-identical to the variants' prior
145    /// inherent implementation.
146    ///
147    /// # Errors
148    ///
149    /// Propagates any error from [`joint_log_likelihood`](BaseNB::joint_log_likelihood).
150    fn nb_predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
151        let jll = self.joint_log_likelihood(x)?;
152        Ok(crate::log_softmax_rows(&jll))
153    }
154
155    /// Return probability estimates: `exp(predict_log_proba)`.
156    ///
157    /// Mirrors sklearn `_BaseNB.predict_proba` (`sklearn/naive_bayes.py:128-144`,
158    /// `np.exp(self.predict_log_proba(X))`). Each row sums to 1.
159    ///
160    /// # Errors
161    ///
162    /// Propagates any error from [`joint_log_likelihood`](BaseNB::joint_log_likelihood).
163    fn nb_predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
164        let mut log_proba = self.nb_predict_log_proba(x)?;
165        log_proba.mapv_inplace(|v| v.exp());
166        Ok(log_proba)
167    }
168
169    /// Return the unnormalized joint log-probability estimates.
170    ///
171    /// Mirrors sklearn `_BaseNB.predict_joint_log_proba`
172    /// (`sklearn/naive_bayes.py:62-84`, returns `self._joint_log_likelihood(X)`).
173    ///
174    /// # Errors
175    ///
176    /// Propagates any error from [`joint_log_likelihood`](BaseNB::joint_log_likelihood).
177    fn nb_predict_joint_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
178        self.joint_log_likelihood(x)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use ndarray::array;
186
187    /// A minimal `BaseNB` implementor over a fixed joint-log-likelihood matrix,
188    /// used to exercise the provided pipeline methods in isolation.
189    struct StubNB {
190        classes: Vec<usize>,
191        jll: Array2<f64>,
192    }
193
194    impl BaseNB<f64> for StubNB {
195        fn joint_log_likelihood(&self, _x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
196            Ok(self.jll.clone())
197        }
198        fn nb_classes(&self) -> &[usize] {
199            &self.classes
200        }
201    }
202
203    #[test]
204    fn test_nb_predict_argmax_to_classes() {
205        // Row 0: class 0 wins; row 1: class 1 wins.
206        let stub = StubNB {
207            classes: vec![3, 7],
208            jll: array![[1.0, 0.5], [0.2, 0.9]],
209        };
210        let x = Array2::<f64>::zeros((2, 1));
211        let preds = stub.nb_predict(&x).unwrap();
212        assert_eq!(preds[0], 3);
213        assert_eq!(preds[1], 7);
214    }
215
216    #[test]
217    fn test_nb_predict_tie_breaks_to_smallest_index() {
218        // Equal scores -> np.argmax first-max -> column 0 -> smallest label.
219        let stub = StubNB {
220            classes: vec![5, 9],
221            jll: array![[2.0, 2.0]],
222        };
223        let x = Array2::<f64>::zeros((1, 1));
224        let preds = stub.nb_predict(&x).unwrap();
225        assert_eq!(preds[0], 5);
226    }
227
228    #[test]
229    fn test_nb_predict_proba_is_exp_of_log_proba_and_sums_to_one() {
230        let stub = StubNB {
231            classes: vec![0, 1],
232            jll: array![[1.0, -0.5], [0.0, 0.0]],
233        };
234        let x = Array2::<f64>::zeros((2, 1));
235        let log_proba = stub.nb_predict_log_proba(&x).unwrap();
236        let proba = stub.nb_predict_proba(&x).unwrap();
237        for i in 0..2 {
238            for j in 0..2 {
239                assert!((proba[[i, j]] - log_proba[[i, j]].exp()).abs() < 1e-15);
240            }
241            assert!((proba.row(i).sum() - 1.0).abs() < 1e-12);
242        }
243    }
244
245    #[test]
246    fn test_nb_predict_joint_log_proba_is_jll() {
247        let jll = array![[1.0, 2.0], [3.0, 4.0]];
248        let stub = StubNB {
249            classes: vec![0, 1],
250            jll: jll.clone(),
251        };
252        let x = Array2::<f64>::zeros((2, 1));
253        let out = stub.nb_predict_joint_log_proba(&x).unwrap();
254        assert_eq!(out, jll);
255    }
256
257    #[test]
258    fn test_check_alpha_force_alpha_keeps_value() {
259        // force_alpha = true -> returned unchanged, even below the floor.
260        assert_eq!(check_alpha::<f64>(0.0, true), 0.0);
261        assert_eq!(check_alpha::<f64>(1e-20, true), 1e-20);
262    }
263
264    #[test]
265    fn test_check_alpha_floors_when_not_forced() {
266        // force_alpha = false -> raised to the 1e-10 lower bound
267        // (sklearn naive_bayes.py:618 alpha_lower_bound = 1e-10).
268        assert_eq!(check_alpha::<f64>(0.0, false), 1e-10);
269        assert_eq!(check_alpha::<f64>(1e-20, false), 1e-10);
270        // Above the floor -> unchanged.
271        assert_eq!(check_alpha::<f64>(1.0, false), 1.0);
272    }
273}