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}