ferrolearn_bayes/complement.rs
1//! Complement Naive Bayes classifier.
2//!
3//! This module provides [`ComplementNB`], a variant of Multinomial Naive Bayes
4//! that is particularly well-suited for imbalanced datasets. Instead of estimating
5//! the likelihood of a feature given a class, it estimates the likelihood of the
6//! feature given all *other* (complement) classes and inverts the weights.
7//!
8//! The weight for feature `j` in class `c` is:
9//!
10//! ```text
11//! w_cj = log( (N_~cj + alpha) / (N_~c + alpha * n_features) )
12//! ```
13//!
14//! where `N_~cj` is the total count of feature `j` in all classes except `c`,
15//! and `N_~c` is the total count of all features in all classes except `c`.
16//!
17//! Stores weights with sklearn's sign convention (positive
18//! `-log(complement_prob)`), and prediction uses
19//! `argmax_c sum_j x_j * w_cj` — matching sklearn's
20//! `argmax(X @ feature_log_prob.T)` exactly.
21//!
22//! # Examples
23//!
24//! ```
25//! use ferrolearn_bayes::ComplementNB;
26//! use ferrolearn_core::{Fit, Predict};
27//! use ndarray::{array, Array2};
28//!
29//! let x = Array2::from_shape_vec(
30//! (6, 3),
31//! vec![
32//! 5.0, 1.0, 0.0,
33//! 4.0, 2.0, 0.0,
34//! 6.0, 0.0, 1.0,
35//! 0.0, 1.0, 5.0,
36//! 1.0, 0.0, 4.0,
37//! 0.0, 2.0, 6.0,
38//! ],
39//! ).unwrap();
40//! let y = array![0usize, 0, 0, 1, 1, 1];
41//!
42//! let model = ComplementNB::<f64>::new();
43//! let fitted = model.fit(&x, &y).unwrap();
44//! let preds = fitted.predict(&x).unwrap();
45//! assert_eq!(preds.len(), 6);
46//! ```
47//!
48//! # `## REQ status`
49//!
50//! Binary classification (R-DEFER-2): two states only — SHIPPED needs impl + a
51//! non-test production consumer + green verification; NOT-STARTED carries the
52//! open prereq blocker. The non-test production consumer is `_RsComplementNB` /
53//! `RsComplementNB` (`ferrolearn-python/src/extras.rs`, built via the
54//! `py_classifier!` macro), which exercises `new(alpha, fit_prior, norm)` / `fit`
55//! / `predict` against the library `FittedComplementNB` and is surfaced as
56//! `ferrolearn.ComplementNB`; plus the in-crate `impl PipelineEstimator for
57//! ComplementNB` (`fit_pipeline` / `predict_pipeline`). The pipeline adapter
58//! preserves the ORIGINAL labels: `fit_pipeline` sets `classes_ = np.unique(y)`
59//! (sorted unique original float labels, via `label_binarize`) and
60//! `predict_pipeline` returns those original labels (`classes_[argmax(jll)]`,
61//! `naive_bayes.py:103`), not `0..n_classes` indices. Green verification = the
62//! in-tree `complement` lib tests plus the live-sklearn pin / guards
63//! (`ferrolearn-bayes/tests/divergence_complement.rs`):
64//! `divergence_complement_negative_alpha_rejected` (#914, now PASSING after the
65//! `alpha < 0` reject landed in `fn fit`), then the green guards
66//! `green_complement_predict_value_norm_false`,
67//! `green_complement_predict_value_norm_true`,
68//! `green_complement_class_prior_length_only`,
69//! `green_complement_score_accuracy`,
70//! `green_complement_negative_features_rejected` — all passing. Cites use symbol
71//! anchors (ferrolearn) / `file:line` (sklearn 1.5.2, commit 156ef14). Live
72//! oracle = installed sklearn 1.5.2. (REQ numbering follows the design doc
73//! `.design/bayes/complement.md`; suggested blocker numbers continue the bayes
74//! layer past bernoulli #905-910.)
75//!
76//! | REQ | Status | Evidence |
77//! |---|---|---|
78//! | REQ-1 (`feature_log_prob_` complement-weight + `_joint_log_likelihood` / `predict` / `predict_proba` / `predict_log_proba` / `predict_joint_log_proba` VALUE, norm=False) | SHIPPED | `fn fit` for `ComplementNB` sets `weights[[ci,j]] = -((total_feature_counts[j] - class_feature_counts[ci,j] + alpha) / (complement_total + alpha*n_features)).ln()` — the algebraic identity of `_update_feature_log_prob`'s `-logged` (`naive_bayes.py:1032-1042`: `comp_count = feature_all_ + alpha - feature_count_`; `logged = log(comp_count / comp_count.sum(axis=1, keepdims=True))`; `feature_log_prob_ = -logged`); `impl BaseNB::joint_log_likelihood` for `FittedComplementNB` computes `X @ weights.T` (`scores[[i,ci]] = sum_j x[i,j] * weights[ci,j]`), mirroring `jll = safe_sparse_dot(X, feature_log_prob_.T)` (`naive_bayes.py:1046`); the four `predict_*` delegate to the `BaseNB` provided methods. The single-class `+ class_log_prior_` add (`naive_bayes.py:1047-1048`) is omitted and BENIGN — single-class `class_log_prior_ = [0.0]` and one-column softmax is always `[[1.0]]`. Non-test consumer: `RsComplementNB::fit`/`predict` (`ferrolearn-python/src/extras.rs`, `py_classifier!`) → `FittedComplementNB`, surfaced as `ferrolearn.ComplementNB`; plus `impl PipelineEstimator`. Verified: green guard `green_complement_predict_value_norm_false` — on `X=[[5,1,0],[4,2,0],[6,0,1],[0,1,5],[1,0,4],[0,2,6]]`, `y=[0,0,0,1,1,1]`, `q=[[3,1,1],[0,1,4]]`, sklearn `predict_proba(q) = [[0.9846153846153846, 0.015384615384615375], [0.0002440810349035878, 0.9997559189650967]]`, `predict_joint_log_proba(q) = [[9.216887641752072, 5.058004558392399], [2.9785630167125636, 11.296329183431908]]`, `predict(q) = [0, 1]`; ferrolearn matches to ≤1e-12. In-tree `test_complement_nb_fit_predict` / `test_complement_nb_predict_proba_sums_to_one` / `test_complement_nb_imbalanced_data` / `test_complement_nb_three_classes` / `test_complement_nb_single_class`. |
79//! | REQ-2 (`alpha >= 0` validation) | SHIPPED | `fn fit` rejects `self.alpha < F::zero()` with `FerroError::InvalidParameter { name: "alpha", reason: "alpha must be >= 0 (sklearn Interval[0, inf))" }`, mirroring the shared `_BaseDiscreteNB._parameter_constraints` `alpha: [Interval(Real, 0, None, closed="left"), "array-like"]` (`naive_bayes.py:530`) inherited by `ComplementNB._parameter_constraints` (`naive_bayes.py:1000-1003`) — the HARD `>= 0` reject `_validate_params` enforces at `fit`, DISTINCT from `_check_alpha`'s `1e-10` floor (`naive_bayes.py:604-626`, `force_alpha`-only; `alpha=0` stays allowed). Non-test consumer: `RsComplementNB::fit` (`extras.rs`) maps the `FerroError` → `PyErr`. Verified: green pin `divergence_complement_negative_alpha_rejected` (#914, now PASSING): `with_alpha(-0.5).fit(X,y)` returns `Err` (sklearn raises `InvalidParameterError`, "The 'alpha' parameter of ComplementNB must be a float in the range [0.0, inf) or an array-like. Got -0.5 instead."). |
80//! | REQ-3 (`norm=True` VALUE) | SHIPPED | `fn fit` / `partial_fit` call `fn apply_norm_inplace`, which divides each `weights` row (= `-logged`) by its row sum — the algebraic identity of sklearn's `feature_log_prob_ = logged / logged.sum(axis=1, keepdims=True)` (`naive_bayes.py:1037-1039`); the two minus signs in `(-logged)/sum(-logged)` cancel. Non-test consumer: `RsComplementNB` threads `norm` through `with_norm(norm)` (`extras.rs`); surfaced as `ferrolearn.ComplementNB(norm=...)`. Verified: green guard `green_complement_predict_value_norm_true` — `ComplementNB(norm=True).fit(X,y)` sklearn `predict_proba(q) = [[0.7192390704948571, 0.2807609295051429], [0.13223037910101987, 0.8677696208989801]]`, `predict(q) = [0, 1]`; ferrolearn `with_norm(true)` produces the IDENTICAL proba/labels to ≤1e-12. |
81//! | REQ-4 (`class_prior` LENGTH-only validation — MATCH) | SHIPPED | `fn fit` validates ONLY `priors.len() != n_classes` (then carries the priors), mirroring `_update_class_log_prior` (`naive_bayes.py:589-591`: `if len(class_prior) != n_classes: ValueError; class_log_prior_ = log(class_prior)`) — discrete NB has NO sum-to-1 / non-negativity check. A deliberate MATCH. Non-test consumer: `RsComplementNB` builds `ComplementNB` (the `with_class_prior` path is exercised in-crate + pipeline). Verified: green guard `green_complement_class_prior_length_only` — `with_class_prior([0.5,0.3]).fit(X,y)` SUCCEEDS (sum 0.8; sklearn `class_log_prior_ = log([0.5,0.3])`, NO error), `with_class_prior([0.5]).fit` errors. In-tree `test_complement_nb_class_prior` / `test_complement_nb_class_prior_wrong_length`. (Wrong-length error TYPE differs — `InvalidParameter` vs `ValueError` — folded into REQ-9's surface gap. For ComplementNB `class_prior` is "Not used" in multi-class predict, `naive_bayes.py:929` — only the length decision is observable.) |
82//! | REQ-5 (`force_alpha` floor + `fit_prior` carry) | SHIPPED | `fn fit` / `partial_fit` call `crate::clamp_alpha(self.alpha, self.force_alpha)` (`base::check_alpha`, the `_check_alpha` floor `1e-10` unless `force_alpha`, `naive_bayes.py:604-626`); `fit_prior` is stored (matching sklearn, only the single-class edge case consults the prior — benign here). Non-test consumer: `RsComplementNB` passes `fit_prior` through `with_fit_prior`, `alpha` through `with_alpha`. Verified: with `force_alpha=true` default and `alpha=1`, `score(X,y) = 1.0` (green `green_complement_score_accuracy`); `clamp_alpha(1, true) = 1`. In-tree `test_complement_nb_default`; `base.rs` `test_check_alpha_*`. |
83//! | REQ-6 (`partial_fit` VALUE — same-classes path) | SHIPPED | `FittedComplementNB::partial_fit` accumulates `class_counts` / `feature_counts` for each EXISTING class, then re-derives `total_feature_counts` (the `feature_all_` analog) / `total_all` and recomputes `weights` (same `-log` complement smoothing), re-applying `apply_norm_inplace` when `norm`, mirroring the shared `_BaseDiscreteNB.partial_fit` accumulate-then-recompute (`naive_bayes.py:628-709`, `_count` re-deriving `feature_all_` → `_update_feature_log_prob`). Non-test consumer: in-crate (the PyO3 `partial_fit` gap is REQ-9). Verified: in-tree `test_complement_nb_partial_fit` / `test_complement_nb_partial_fit_shape_mismatch` — chunked `partial_fit` over already-fitted classes reproduces the accumulate-then-recompute path (sklearn two-chunk `partial_fit` == `fit` on the whole, `np.allclose == True`). KNOWN GAP: `partial_fit` has NO `classes=` argument — it loops only over the already-fitted `self.classes`, so a brand-new later-chunk label is silently dropped (sklearn binarizes against the full `classes=` list from the first call, `naive_bayes.py:628-709`); this `classes=`/unseen-label path is NOT-STARTED (folded into #915). |
84//! | REQ-7 (negative-feature guard — both reject) | SHIPPED | `fn fit` (and `partial_fit`) reject any `x[i,j] < 0` with `FerroError::InvalidParameter { name: "X", reason: "ComplementNB requires non-negative feature values" }`, mirroring `check_non_negative(X, "ComplementNB (input X)")` → `ValueError` (`naive_bayes.py:1027`; ComplementNB DOES guard non-negativity, unlike BernoulliNB). Both REJECT. Non-test consumer: `RsComplementNB::fit` (`extras.rs`) maps the `FerroError` to a `PyErr`. Verified: green guard `green_complement_negative_features_rejected` — `ComplementNB().fit(X_neg, y)` returns `Err` (sklearn `ValueError("Negative values in data passed to ComplementNB (input X)")`). In-tree `test_complement_nb_negative_features_error`. The exact sklearn MESSAGE/TYPE is NOT matched — that sub-item is captured under REQ-9. |
85//! | REQ-8 (`sample_weight` + `partial_fit` `classes=`) | NOT-STARTED | open prereq blocker **#915**. sklearn `fit(X, y, sample_weight=None)` (`naive_bayes.py:712`) weights the binarized `Y` so `feature_count_ = Y.T @ X` / `class_count_ = Y.sum(axis=0)` / `feature_all_ = feature_count_.sum(axis=0)` become weighted (`naive_bayes.py:1025-1030`). ferrolearn's `impl Fit<Array2<F>, Array1<usize>>` has signature `fn fit(&self, x, y)` — NO `sample_weight` parameter on `fit` or `partial_fit`; also no `classes=` argument on `partial_fit` (the unseen-label sub-gap of REQ-6). |
86//! | REQ-9a (Rust fitted-attribute accessors) | SHIPPED | `FittedComplementNB` exposes `feature_log_prob(&self) -> &Array2<F>` (`&self.weights`, sklearn `feature_log_prob_`, `naive_bayes.py:1042`), `feature_count(&self) -> &Array2<F>` (`&self.feature_counts`, sklearn `feature_count_`, `naive_bayes.py:961`), `class_count(&self) -> Array1<F>` (the integer `class_counts` cast to `F`, sklearn `class_count_`, `naive_bayes.py:951`), `feature_all(&self) -> Array1<F>` (DERIVED `feature_counts.sum_axis(Axis(0))`, sklearn `feature_all_ = feature_count_.sum(axis=0)`, `naive_bayes.py:1029`), and `class_log_prior(&self) -> Array1<F>` (DERIVED empirical `log(class_count_) - log(class_count_.sum())`, sklearn `class_log_prior_`, `naive_bayes.py:600`). `coef_`/`intercept_` are DEPRECATED and REMOVED in sklearn 1.5.2 (`hasattr(ComplementNB().fit(...), 'coef_') == False`), so no `coef_`/`intercept_` getter is added. Live oracle (`X=[[5,1,0],[4,2,0],[6,0,1],[0,1,5],[1,0,4],[0,2,6]]`, `y=[0,0,0,1,1,1]`): `feature_log_prob_ = [[2.3978952728,1.7047480922,0.3184537311],[0.3184537311,1.7047480922,2.3978952728]]`, `feature_count_ = [[15,3,1],[1,3,15]]`, `class_count_ = [3,3]`, `feature_all_ = [16,6,16]`, `class_log_prior_ = [-0.6931471806,-0.6931471806]`. In-tree `complement_feature_log_prob_and_count_match_sklearn` / `complement_feature_all_class_count_prior_match_sklearn`. |
87//! | REQ-9b (PyO3 surface + `sample_weight`) | NOT-STARTED | open prereq blocker **#916**. `_RsComplementNB` (`ferrolearn-python/src/extras.rs`, the `py_classifier!` macro) exposes ONLY `new(alpha, fit_prior, norm)` + `fit` + `predict` — NO `class_prior`/`force_alpha` kwargs, NO `predict_proba`/`predict_log_proba`/`predict_joint_log_proba`/`score`/`partial_fit` (which the library HAS), NO fitted-attr getters bridged to Python (`feature_log_prob_` / `feature_all_` / `feature_count_` / `class_count_` / `class_log_prior_` / `classes_` / `n_features_in_`). `coef_`/`intercept_` are deprecated/removed in sklearn 1.5.2 (`hasattr == False`) and stay absent. Also subsumes the negative-feature MESSAGE/TYPE-parity sub-item (REQ-7: `InvalidParameter` vs `ValueError`) and the `class_prior` wrong-length TYPE sub-item (REQ-4). The fix belongs in `ferrolearn-python` (multi-file). |
88//! | REQ-10 (ferray substrate) | NOT-STARTED | open prereq blocker **#917**. `complement.rs` imports `ndarray::{Array1, Array2}` + `num_traits::{Float, FromPrimitive, ToPrimitive}` (the wrong substrate, R-SUBSTRATE-1); not migrated to `ferray-core`. |
89//! | REQ-11 (non-finite input rejected, finiteness-FIRST) | SHIPPED | `Fit::fit for ComplementNB` AND `FittedComplementNB::partial_fit` reject any NaN/+/-inf in X (`x.iter().any(\|v\| !v.is_finite())` → `FerroError::InvalidParameter { name: "X", reason: "Input X contains NaN or infinity." }`) ABOVE the existing non-negative-feature guard, mirroring sklearn `_BaseDiscreteNB.fit`/`partial_fit` → `self._check_X_y(X, y)` → `self._validate_data(..., force_all_finite=True)` (`naive_bayes.py:576-578`, `:668`) which runs BEFORE `_count` → `check_non_negative(X, "ComplementNB (input X)")` (`naive_bayes.py:1027`). Finiteness-first verified live: NaN-AND-negative cell → `ValueError("Input X contains NaN.")`, not the negative error. y is integer-typed; `fit`/`partial_fit` take no `sample_weight` (REQ-8 NOT-STARTED), so only X is guarded. Finite path byte-identical (in-tree `complement` tests unchanged). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `tests/divergence_nb_nonfinite.rs::complement_*`. Non-test consumer: the existing `Fit::fit` / `_RsComplementNB` / pipeline consumers. (#2271) |
90
91use crate::base::BaseNB;
92use ferrolearn_core::error::FerroError;
93use ferrolearn_core::introspection::HasClasses;
94use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
95use ferrolearn_core::traits::{Fit, Predict};
96use ndarray::{Array1, Array2};
97use num_traits::{Float, FromPrimitive, ToPrimitive};
98
99/// Complement Naive Bayes classifier.
100///
101/// A variant of Multinomial NB that uses complement-class statistics.
102/// More robust for imbalanced datasets.
103///
104/// # Type Parameters
105///
106/// - `F`: The floating-point type (`f32` or `f64`).
107#[derive(Debug, Clone)]
108pub struct ComplementNB<F> {
109 /// Additive (Laplace) smoothing parameter. Default: `1.0`.
110 pub alpha: F,
111 /// Optional user-supplied class priors. Note: ComplementNB does not
112 /// use priors in the standard way (it uses complement weights), but
113 /// this field is provided for API consistency with other NB variants.
114 pub class_prior: Option<Vec<F>>,
115 /// Whether to learn class priors from the data. Stored for API
116 /// consistency; ComplementNB's predict does not consult priors in the
117 /// multi-class case. Default: `true`.
118 pub fit_prior: bool,
119 /// When `false`, `alpha` values below `1e-10` are silently raised to
120 /// `1e-10` (legacy behavior). Default: `true`.
121 pub force_alpha: bool,
122 /// When `true`, performs a second L1 normalization of the weights
123 /// (Rennie et al. 2003 §4.4 "normalized weights" variant). Default:
124 /// `false`.
125 pub norm: bool,
126}
127
128impl<F: Float> ComplementNB<F> {
129 /// Create a new `ComplementNB` with Laplace smoothing (`alpha = 1.0`).
130 #[must_use]
131 pub fn new() -> Self {
132 Self {
133 alpha: F::one(),
134 class_prior: None,
135 fit_prior: true,
136 force_alpha: true,
137 norm: false,
138 }
139 }
140
141 /// Set the Laplace smoothing parameter.
142 #[must_use]
143 pub fn with_alpha(mut self, alpha: F) -> Self {
144 self.alpha = alpha;
145 self
146 }
147
148 /// Set user-supplied class priors.
149 ///
150 /// The priors must have length equal to the number of classes discovered
151 /// during fitting. Note: ComplementNB uses complement-class weights rather
152 /// than direct class priors, but the priors are stored for API consistency.
153 #[must_use]
154 pub fn with_class_prior(mut self, priors: Vec<F>) -> Self {
155 self.class_prior = Some(priors);
156 self
157 }
158
159 /// Toggle `fit_prior`. Stored for API consistency with other discrete NBs.
160 #[must_use]
161 pub fn with_fit_prior(mut self, fit_prior: bool) -> Self {
162 self.fit_prior = fit_prior;
163 self
164 }
165
166 /// Toggle the `force_alpha` policy. See struct field doc.
167 #[must_use]
168 pub fn with_force_alpha(mut self, force_alpha: bool) -> Self {
169 self.force_alpha = force_alpha;
170 self
171 }
172
173 /// Toggle the second L1 normalization on weights (sklearn's `norm`
174 /// parameter; Rennie et al. 2003 §4.4).
175 #[must_use]
176 pub fn with_norm(mut self, norm: bool) -> Self {
177 self.norm = norm;
178 self
179 }
180}
181
182impl<F: Float> Default for ComplementNB<F> {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188/// Fitted Complement Naive Bayes classifier.
189#[derive(Debug, Clone)]
190pub struct FittedComplementNB<F> {
191 /// Sorted unique class labels.
192 classes: Vec<usize>,
193 /// Complement weights per class, shape `(n_classes, n_features)`.
194 /// Each entry is `log( (N_~cj + alpha) / (N_~c + alpha * n_features) )`.
195 weights: Array2<F>,
196 /// Raw per-class feature count sums, shape `(n_classes, n_features)`.
197 feature_counts: Array2<F>,
198 /// Per-class sample counts.
199 class_counts: Vec<usize>,
200 /// Smoothing parameter carried forward for partial_fit (post-clamp
201 /// when `force_alpha=false`).
202 alpha: F,
203 /// Whether to apply the second L1 normalization on weights (carried
204 /// forward for partial_fit).
205 norm: bool,
206}
207
208impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for ComplementNB<F> {
209 type Fitted = FittedComplementNB<F>;
210 type Error = FerroError;
211
212 /// Fit the Complement NB model.
213 ///
214 /// # Errors
215 ///
216 /// - [`FerroError::ShapeMismatch`] if `x` and `y` have different numbers of rows.
217 /// - [`FerroError::InsufficientSamples`] if there are no samples.
218 /// - [`FerroError::InvalidParameter`] if any feature value is negative.
219 fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedComplementNB<F>, FerroError> {
220 let (n_samples, n_features) = x.dim();
221
222 if n_samples == 0 {
223 return Err(FerroError::InsufficientSamples {
224 required: 1,
225 actual: 0,
226 context: "ComplementNB requires at least one sample".into(),
227 });
228 }
229
230 if n_samples != y.len() {
231 return Err(FerroError::ShapeMismatch {
232 expected: vec![n_samples],
233 actual: vec![y.len()],
234 context: "y length must match number of samples in X".into(),
235 });
236 }
237
238 // sklearn `_BaseDiscreteNB.fit` -> `self._check_X_y(X, y)` ->
239 // `self._validate_data(X, y, accept_sparse="csr", reset=...)`
240 // (`naive_bayes.py:576-578`, `force_all_finite=True`) raises
241 // `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
242 // for any NaN/+/-inf in X BEFORE `_count` ->
243 // `check_non_negative(X, "ComplementNB (input X)")`
244 // (`naive_bayes.py:1027`). Finiteness is validated FIRST: a NaN-AND-
245 // negative cell yields the NaN error, not the negative one (verified
246 // live). Guard finiteness ABOVE the non-negative guard. y is integer-
247 // typed; ferrolearn `fit` takes no `sample_weight` (REQ-8 NOT-STARTED),
248 // so only X is guarded. (#2271)
249 if x.iter().any(|v| !v.is_finite()) {
250 return Err(FerroError::InvalidParameter {
251 name: "X".into(),
252 reason: "Input X contains NaN or infinity.".into(),
253 });
254 }
255
256 // Validate non-negative features.
257 if x.iter().any(|&v| v < F::zero()) {
258 return Err(FerroError::InvalidParameter {
259 name: "X".into(),
260 reason: "ComplementNB requires non-negative feature values".into(),
261 });
262 }
263
264 // Collect sorted unique classes.
265 let mut classes: Vec<usize> = y.to_vec();
266 classes.sort_unstable();
267 classes.dedup();
268 let n_classes = classes.len();
269
270 let n_feat_f = F::from(n_features).unwrap();
271 // sklearn rejects alpha < 0 at fit via _parameter_constraints
272 // `alpha: Interval(Real, 0, None, closed="left")` (naive_bayes.py:530,
273 // inherited by ComplementNB at :1000-1003) — a HARD reject distinct
274 // from `_check_alpha`'s 1e-10 floor (:619, force_alpha-only).
275 if self.alpha < F::zero() {
276 return Err(FerroError::InvalidParameter {
277 name: "alpha".into(),
278 reason: "alpha must be >= 0 (sklearn Interval[0, inf))".into(),
279 });
280 }
281
282 let alpha = crate::clamp_alpha(self.alpha, self.force_alpha);
283
284 // Compute per-class feature count sums, shape (n_classes, n_features).
285 let mut class_feature_counts = Array2::<F>::zeros((n_classes, n_features));
286 let mut class_counts = vec![0usize; n_classes];
287
288 for (sample_idx, &label) in y.iter().enumerate() {
289 let ci = classes.iter().position(|&c| c == label).unwrap();
290 class_counts[ci] += 1;
291 for j in 0..n_features {
292 class_feature_counts[[ci, j]] = class_feature_counts[[ci, j]] + x[[sample_idx, j]];
293 }
294 }
295
296 // Total feature counts across all classes.
297 let total_feature_counts: Array1<F> = class_feature_counts.rows().into_iter().fold(
298 Array1::<F>::zeros(n_features),
299 |acc, row| {
300 let mut result = acc;
301 for j in 0..n_features {
302 result[j] = result[j] + row[j];
303 }
304 result
305 },
306 );
307
308 let total_all: F = total_feature_counts.sum();
309
310 // Compute complement-log weights for each class. sklearn stores
311 // `feature_log_prob_ = -log((complement_count + alpha) / (total + alpha*n_features))`
312 // (positive values — see #346). ferrolearn previously stored the
313 // pre-negation value; we now match sklearn's convention so
314 // introspection is parity-correct and predict uses argmax.
315 let mut weights = Array2::<F>::zeros((n_classes, n_features));
316
317 for ci in 0..n_classes {
318 // Complement counts: sum over all other classes.
319 let complement_total = total_all - class_feature_counts.row(ci).sum();
320
321 let denom = complement_total + alpha * n_feat_f;
322
323 for j in 0..n_features {
324 let complement_count_j = total_feature_counts[j] - class_feature_counts[[ci, j]];
325 // Negate so the stored value matches sklearn's
326 // `feature_log_prob_` exactly: positive values whose
327 // *smaller* indicates higher complement probability.
328 weights[[ci, j]] = -((complement_count_j + alpha) / denom).ln();
329 }
330 }
331
332 if self.norm {
333 apply_norm_inplace(&mut weights);
334 }
335
336 // Validate class_prior length if provided.
337 if let Some(ref priors) = self.class_prior
338 && priors.len() != n_classes
339 {
340 return Err(FerroError::InvalidParameter {
341 name: "class_prior".into(),
342 reason: format!(
343 "length {} does not match number of classes {}",
344 priors.len(),
345 n_classes
346 ),
347 });
348 }
349
350 Ok(FittedComplementNB {
351 classes,
352 weights,
353 feature_counts: class_feature_counts,
354 class_counts,
355 alpha,
356 norm: self.norm,
357 })
358 }
359}
360
361/// Apply sklearn's `norm=True` second L1 normalization to complement weights.
362///
363/// `weights` is already stored as sklearn's positive `-log(complement_prob)`.
364/// sklearn divides each row by its sum so rows sum to 1 (still positive,
365/// since the unnormalised values are positive).
366fn apply_norm_inplace<F: Float>(weights: &mut Array2<F>) {
367 let n_classes = weights.nrows();
368 let n_features = weights.ncols();
369 for ci in 0..n_classes {
370 let row_sum = (0..n_features).fold(F::zero(), |acc, j| acc + weights[[ci, j]]);
371 if row_sum == F::zero() {
372 continue;
373 }
374 for j in 0..n_features {
375 weights[[ci, j]] = weights[[ci, j]] / row_sum;
376 }
377 }
378}
379
380impl<F: Float + Send + Sync + 'static> FittedComplementNB<F> {
381 /// Incrementally update the model with new data.
382 ///
383 /// Accumulates feature counts and class counts, then recomputes
384 /// the complement weights.
385 ///
386 /// # Errors
387 ///
388 /// - [`FerroError::ShapeMismatch`] if `x` and `y` have different row counts
389 /// or the number of features does not match the fitted model.
390 /// - [`FerroError::InvalidParameter`] if any feature value is negative.
391 pub fn partial_fit(&mut self, x: &Array2<F>, y: &Array1<usize>) -> Result<(), FerroError> {
392 let (n_samples, n_features) = x.dim();
393
394 if n_samples == 0 {
395 return Ok(());
396 }
397
398 if n_samples != y.len() {
399 return Err(FerroError::ShapeMismatch {
400 expected: vec![n_samples],
401 actual: vec![y.len()],
402 context: "y length must match number of samples in X".into(),
403 });
404 }
405
406 if n_features != self.weights.ncols() {
407 return Err(FerroError::ShapeMismatch {
408 expected: vec![self.weights.ncols()],
409 actual: vec![n_features],
410 context: "number of features must match fitted ComplementNB".into(),
411 });
412 }
413
414 // sklearn `_BaseDiscreteNB.partial_fit` -> `self._check_X_y(X, y, ...)`
415 // (`naive_bayes.py:668`, `force_all_finite=True`) BEFORE `_count` ->
416 // `check_non_negative` (`naive_bayes.py:1027`): finiteness FIRST. Guard
417 // X for NaN/+/-inf ABOVE the non-negative guard. (#2271)
418 if x.iter().any(|v| !v.is_finite()) {
419 return Err(FerroError::InvalidParameter {
420 name: "X".into(),
421 reason: "Input X contains NaN or infinity.".into(),
422 });
423 }
424
425 if x.iter().any(|&v| v < F::zero()) {
426 return Err(FerroError::InvalidParameter {
427 name: "X".into(),
428 reason: "ComplementNB requires non-negative feature values".into(),
429 });
430 }
431
432 // Accumulate counts for each existing class.
433 for (ci, &class_label) in self.classes.clone().iter().enumerate() {
434 let new_indices: Vec<usize> = y
435 .iter()
436 .enumerate()
437 .filter_map(|(i, &label)| if label == class_label { Some(i) } else { None })
438 .collect();
439
440 if new_indices.is_empty() {
441 continue;
442 }
443
444 self.class_counts[ci] += new_indices.len();
445
446 for &i in &new_indices {
447 for j in 0..n_features {
448 self.feature_counts[[ci, j]] = self.feature_counts[[ci, j]] + x[[i, j]];
449 }
450 }
451 }
452
453 // Recompute complement weights from accumulated feature_counts.
454 let n_classes = self.classes.len();
455 let n_feat_f = F::from(n_features).unwrap();
456
457 let total_feature_counts: Array1<F> = self.feature_counts.rows().into_iter().fold(
458 Array1::<F>::zeros(n_features),
459 |acc, row| {
460 let mut result = acc;
461 for j in 0..n_features {
462 result[j] = result[j] + row[j];
463 }
464 result
465 },
466 );
467
468 let total_all: F = total_feature_counts.sum();
469
470 for ci in 0..n_classes {
471 let complement_total = total_all - self.feature_counts.row(ci).sum();
472 let denom = complement_total + self.alpha * n_feat_f;
473 for j in 0..n_features {
474 let complement_count_j = total_feature_counts[j] - self.feature_counts[[ci, j]];
475 // sklearn-parity sign: positive -log(complement_prob).
476 self.weights[[ci, j]] = -((complement_count_j + self.alpha) / denom).ln();
477 }
478 }
479
480 if self.norm {
481 apply_norm_inplace(&mut self.weights);
482 }
483
484 Ok(())
485 }
486
487 /// Predict class probabilities for the given feature matrix.
488 ///
489 /// Returns shape `(n_samples, n_classes)` where each row sums to 1.
490 /// Delegates to [`BaseNB::nb_predict_proba`] — with ComplementNB's
491 /// sklearn-parity sign, the joint log-likelihood is `X @ weights.T`
492 /// directly, so `exp(jll - logsumexp(jll))` is the softmax of the
493 /// complement scores.
494 ///
495 /// # Errors
496 ///
497 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
498 /// not match the fitted model.
499 pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
500 BaseNB::nb_predict_proba(self, x)
501 }
502
503 /// Compute the joint log-likelihood scores using sklearn's sign
504 /// convention: argmax(jll) gives the predicted class.
505 ///
506 /// Returns shape `(n_samples, n_classes)`. With the sklearn-parity sign,
507 /// `X @ weights.T` IS the joint log-likelihood. Matches sklearn
508 /// `ComplementNB._joint_log_likelihood`. Delegates to
509 /// [`BaseNB::nb_predict_joint_log_proba`].
510 ///
511 /// # Errors
512 ///
513 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
514 /// not match the fitted model.
515 pub fn predict_joint_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
516 BaseNB::nb_predict_joint_log_proba(self, x)
517 }
518
519 /// Compute log of class probabilities (numerically stable).
520 ///
521 /// Returns shape `(n_samples, n_classes)`. Delegates to
522 /// [`BaseNB::nb_predict_log_proba`].
523 ///
524 /// # Errors
525 ///
526 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
527 /// not match the fitted model.
528 pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
529 BaseNB::nb_predict_log_proba(self, x)
530 }
531
532 /// Mean accuracy on the given test data and labels.
533 ///
534 /// Equivalent to sklearn's `ClassifierMixin.score`.
535 ///
536 /// # Errors
537 ///
538 /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
539 /// the feature count does not match the fitted model.
540 pub fn score(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<F, FerroError> {
541 if x.nrows() != y.len() {
542 return Err(FerroError::ShapeMismatch {
543 expected: vec![x.nrows()],
544 actual: vec![y.len()],
545 context: "y length must match number of samples in X".into(),
546 });
547 }
548 let preds = self.predict(x)?;
549 let n = y.len();
550 if n == 0 {
551 return Ok(F::zero());
552 }
553 let correct = preds.iter().zip(y.iter()).filter(|(p, t)| p == t).count();
554 Ok(F::from(correct).unwrap() / F::from(n).unwrap())
555 }
556}
557
558impl<F: Float + Send + Sync + 'static> FittedComplementNB<F> {
559 /// Empirical complement weights (the negated smoothed complement-class
560 /// log-probabilities), shape `(n_classes, n_features)`.
561 ///
562 /// Mirrors sklearn `ComplementNB.feature_log_prob_`
563 /// (`_update_feature_log_prob`, `naive_bayes.py:1042`).
564 #[must_use]
565 pub fn feature_log_prob(&self) -> &Array2<F> {
566 &self.weights
567 }
568
569 /// Number of samples encountered for each (class, feature) during fitting,
570 /// shape `(n_classes, n_features)`.
571 ///
572 /// Mirrors sklearn `ComplementNB.feature_count_`
573 /// (`_count`, `naive_bayes.py:961`).
574 #[must_use]
575 pub fn feature_count(&self) -> &Array2<F> {
576 &self.feature_counts
577 }
578
579 /// Number of samples encountered for each class during fitting,
580 /// shape `(n_classes,)`.
581 ///
582 /// Mirrors sklearn `ComplementNB.class_count_`
583 /// (`_count`, `naive_bayes.py:951`). `class_counts` is stored as integer
584 /// counts; this casts each to `F`.
585 #[must_use]
586 pub fn class_count(&self) -> Array1<F> {
587 Array1::from_iter(
588 self.class_counts
589 .iter()
590 .map(|&c| F::from(c).unwrap_or_else(F::zero)),
591 )
592 }
593
594 /// Number of samples encountered for each feature during fitting (the
595 /// per-feature total across all classes), shape `(n_features,)`.
596 ///
597 /// Derived (not stored) as `feature_count_.sum(axis=0)`, mirroring sklearn
598 /// `ComplementNB.feature_all_` (`_count`, `feature_all_ =
599 /// feature_count_.sum(axis=0)`, `naive_bayes.py:1029`).
600 #[must_use]
601 pub fn feature_all(&self) -> Array1<F> {
602 self.feature_counts.sum_axis(ndarray::Axis(0))
603 }
604
605 /// Smoothed empirical log probability for each class, shape `(n_classes,)`.
606 ///
607 /// Derived (not stored) as `log(class_count_) - log(class_count_.sum())`,
608 /// mirroring sklearn's EMPIRICAL `class_log_prior_` under the default
609 /// `fit_prior=True` (`_update_class_log_prior`, `naive_bayes.py:600`).
610 /// ComplementNB stores the empirical class-prior derivation; this returns
611 /// the EMPIRICAL prior (matching sklearn's `class_log_prior_` value on any
612 /// fit). Note: ComplementNB only consults `class_log_prior_` in the
613 /// single-class edge case (`naive_bayes.py:1047-1048`); it does not affect
614 /// multi-class predictions.
615 #[must_use]
616 pub fn class_log_prior(&self) -> Array1<F> {
617 let total = self.class_counts.iter().fold(F::zero(), |acc, &c| {
618 acc + F::from(c).unwrap_or_else(F::zero)
619 });
620 Array1::from_iter(
621 self.class_counts
622 .iter()
623 .map(|&c| (F::from(c).unwrap_or_else(F::zero) / total).ln()),
624 )
625 }
626}
627
628impl<F: Float + Send + Sync + 'static> BaseNB<F> for FittedComplementNB<F> {
629 /// Compute the joint log-likelihood scores for each class — sklearn
630 /// `ComplementNB._joint_log_likelihood`.
631 ///
632 /// Returns `X @ feature_log_prob_.T` (shape `(n_samples, n_classes)`).
633 /// With ferrolearn's sklearn-parity sign for `feature_log_prob_`,
634 /// **higher is better** and `argmax(scores, axis=1)` predicts the class.
635 fn joint_log_likelihood(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
636 let n_features_fitted = self.weights.ncols();
637 if x.ncols() != n_features_fitted {
638 return Err(FerroError::ShapeMismatch {
639 expected: vec![n_features_fitted],
640 actual: vec![x.ncols()],
641 context: "number of features must match fitted ComplementNB".into(),
642 });
643 }
644
645 let n_samples = x.nrows();
646 let n_classes = self.classes.len();
647 let n_features = x.ncols();
648
649 let mut scores = Array2::<F>::zeros((n_samples, n_classes));
650
651 for i in 0..n_samples {
652 for ci in 0..n_classes {
653 let mut score = F::zero();
654 for j in 0..n_features {
655 score = score + x[[i, j]] * self.weights[[ci, j]];
656 }
657 scores[[i, ci]] = score;
658 }
659 }
660
661 Ok(scores)
662 }
663
664 fn nb_classes(&self) -> &[usize] {
665 &self.classes
666 }
667}
668
669impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedComplementNB<F> {
670 type Output = Array1<usize>;
671 type Error = FerroError;
672
673 /// Predict class labels for the given feature matrix.
674 ///
675 /// With ComplementNB's sklearn-parity sign, the highest joint
676 /// log-likelihood wins. Delegates to [`BaseNB::nb_predict`].
677 ///
678 /// # Errors
679 ///
680 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
681 /// not match the fitted model.
682 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
683 BaseNB::nb_predict(self, x)
684 }
685}
686
687impl<F: Float + Send + Sync + 'static> HasClasses for FittedComplementNB<F> {
688 fn classes(&self) -> &[usize] {
689 &self.classes
690 }
691
692 fn n_classes(&self) -> usize {
693 self.classes.len()
694 }
695}
696
697// Pipeline integration.
698impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> PipelineEstimator<F>
699 for ComplementNB<F>
700{
701 fn fit_pipeline(
702 &self,
703 x: &Array2<F>,
704 y: &Array1<F>,
705 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
706 // sklearn `ComplementNB.fit` sets `classes_ = np.unique(y)` — the sorted
707 // unique ORIGINAL labels (via `label_binarize`); `predict` returns
708 // `self.classes_[np.argmax(jll, axis=1)]` — the original labels, NOT
709 // class indices (`naive_bayes.py:103`). Preserve the original float
710 // labels here instead of collapsing them to usize indices.
711 let mut classes_orig: Vec<F> = y.to_vec();
712 classes_orig.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
713 classes_orig.dedup();
714 // Map each label to its index into `classes_orig` (0..n_classes).
715 let y_idx: Array1<usize> =
716 y.mapv(|v| classes_orig.iter().position(|&c| c == v).unwrap_or(0));
717 let fitted = self.fit(x, &y_idx)?;
718 Ok(Box::new(FittedComplementNBPipeline {
719 fitted,
720 classes_orig,
721 }))
722 }
723}
724
725struct FittedComplementNBPipeline<F: Float + Send + Sync + 'static> {
726 fitted: FittedComplementNB<F>,
727 classes_orig: Vec<F>,
728}
729
730// SAFETY: `FittedComplementNB<F>` and `Vec<F>` are both Send when `F: Send`; this
731// mirrors the existing inner-type bound and adds no interior mutability.
732unsafe impl<F: Float + Send + Sync + 'static> Send for FittedComplementNBPipeline<F> {}
733// SAFETY: `FittedComplementNB<F>` and `Vec<F>` are both Sync when `F: Sync`; no
734// shared interior mutability is introduced.
735unsafe impl<F: Float + Send + Sync + 'static> Sync for FittedComplementNBPipeline<F> {}
736
737impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> FittedPipelineEstimator<F>
738 for FittedComplementNBPipeline<F>
739{
740 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
741 // `self.fitted.predict` returns the class indices (`0..n_classes`) the
742 // model was trained on; map each back to the original label, mirroring
743 // sklearn `classes_[argmax(jll)]` (`naive_bayes.py:103`).
744 let preds = self.fitted.predict(x)?;
745 Ok(preds.mapv(|i| self.classes_orig.get(i).copied().unwrap_or_else(F::nan)))
746 }
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use approx::assert_relative_eq;
753 use ndarray::array;
754
755 fn make_count_data() -> (Array2<f64>, Array1<usize>) {
756 let x = Array2::from_shape_vec(
757 (6, 3),
758 vec![
759 5.0, 1.0, 0.0, 4.0, 2.0, 0.0, 6.0, 0.0, 1.0, 0.0, 1.0, 5.0, 1.0, 0.0, 4.0, 0.0,
760 2.0, 6.0,
761 ],
762 )
763 .unwrap();
764 let y = array![0usize, 0, 0, 1, 1, 1];
765 (x, y)
766 }
767
768 #[test]
769 fn test_complement_nb_fit_predict() {
770 let (x, y) = make_count_data();
771 let model = ComplementNB::<f64>::new();
772 let fitted = model.fit(&x, &y).unwrap();
773 let preds = fitted.predict(&x).unwrap();
774 let correct = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
775 assert_eq!(correct, 6);
776 }
777
778 #[test]
779 fn test_complement_nb_predict_proba_sums_to_one() {
780 let (x, y) = make_count_data();
781 let model = ComplementNB::<f64>::new();
782 let fitted = model.fit(&x, &y).unwrap();
783 let proba = fitted.predict_proba(&x).unwrap();
784 for i in 0..proba.nrows() {
785 assert_relative_eq!(proba.row(i).sum(), 1.0, epsilon = 1e-10);
786 }
787 }
788
789 #[test]
790 fn test_complement_nb_has_classes() {
791 let (x, y) = make_count_data();
792 let model = ComplementNB::<f64>::new();
793 let fitted = model.fit(&x, &y).unwrap();
794 assert_eq!(fitted.classes(), &[0, 1]);
795 assert_eq!(fitted.n_classes(), 2);
796 }
797
798 #[test]
799 fn test_complement_nb_shape_mismatch_fit() {
800 let x = Array2::from_shape_vec((4, 3), vec![1.0; 12]).unwrap();
801 let y = array![0usize, 1]; // Wrong length
802 let model = ComplementNB::<f64>::new();
803 assert!(model.fit(&x, &y).is_err());
804 }
805
806 #[test]
807 fn test_complement_nb_shape_mismatch_predict() {
808 let (x, y) = make_count_data();
809 let model = ComplementNB::<f64>::new();
810 let fitted = model.fit(&x, &y).unwrap();
811 let x_bad = Array2::from_shape_vec((3, 5), vec![1.0; 15]).unwrap();
812 assert!(fitted.predict(&x_bad).is_err());
813 assert!(fitted.predict_proba(&x_bad).is_err());
814 }
815
816 #[test]
817 fn test_complement_nb_negative_features_error() {
818 let x =
819 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, -0.5, 3.0, 2.0, 1.0, 0.0, 4.0]).unwrap();
820 let y = array![0usize, 0, 1, 1];
821 let model = ComplementNB::<f64>::new();
822 assert!(model.fit(&x, &y).is_err());
823 }
824
825 #[test]
826 fn test_complement_nb_single_class() {
827 let x = Array2::from_shape_vec((3, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
828 .unwrap();
829 let y = array![0usize, 0, 0];
830 let model = ComplementNB::<f64>::new();
831 let fitted = model.fit(&x, &y).unwrap();
832 assert_eq!(fitted.classes(), &[0]);
833 let preds = fitted.predict(&x).unwrap();
834 assert!(preds.iter().all(|&p| p == 0));
835 }
836
837 #[test]
838 fn test_complement_nb_empty_data() {
839 let x = Array2::<f64>::zeros((0, 3));
840 let y = Array1::<usize>::zeros(0);
841 let model = ComplementNB::<f64>::new();
842 assert!(model.fit(&x, &y).is_err());
843 }
844
845 #[test]
846 fn test_complement_nb_default() {
847 let model = ComplementNB::<f64>::default();
848 assert_relative_eq!(model.alpha, 1.0, epsilon = 1e-15);
849 }
850
851 #[test]
852 fn test_complement_nb_imbalanced_data() {
853 // ComplementNB is designed for imbalanced data.
854 // 10 samples of class 0, 2 samples of class 1.
855 let x = Array2::from_shape_vec(
856 (12, 3),
857 vec![
858 5.0, 1.0, 0.0, 4.0, 2.0, 0.0, 6.0, 0.0, 1.0, 5.0, 1.0, 0.0, 4.0, 2.0, 0.0, 6.0,
859 0.0, 1.0, 5.0, 1.0, 0.0, 4.0, 2.0, 0.0, 6.0, 0.0, 1.0, 5.0, 1.0, 0.0, 0.0, 1.0,
860 5.0, // class 1
861 0.0, 2.0, 6.0, // class 1
862 ],
863 )
864 .unwrap();
865 let y = array![0usize, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1];
866
867 let model = ComplementNB::<f64>::new();
868 let fitted = model.fit(&x, &y).unwrap();
869 let preds = fitted.predict(&x).unwrap();
870
871 // Class 1 samples should be predicted as class 1.
872 assert_eq!(preds[10], 1);
873 assert_eq!(preds[11], 1);
874 }
875
876 #[test]
877 fn test_complement_nb_partial_fit() {
878 let x1 = Array2::from_shape_vec(
879 (4, 3),
880 vec![5.0, 1.0, 0.0, 4.0, 2.0, 0.0, 0.0, 1.0, 5.0, 1.0, 0.0, 4.0],
881 )
882 .unwrap();
883 let y1 = array![0usize, 0, 1, 1];
884
885 let model = ComplementNB::<f64>::new();
886 let mut fitted = model.fit(&x1, &y1).unwrap();
887
888 let x2 = Array2::from_shape_vec((2, 3), vec![6.0, 0.0, 1.0, 0.0, 2.0, 6.0]).unwrap();
889 let y2 = array![0usize, 1];
890
891 fitted.partial_fit(&x2, &y2).unwrap();
892
893 let preds = fitted.predict(&x1).unwrap();
894 assert_eq!(preds.len(), 4);
895 }
896
897 #[test]
898 fn test_complement_nb_partial_fit_shape_mismatch() {
899 let (x, y) = make_count_data();
900 let model = ComplementNB::<f64>::new();
901 let mut fitted = model.fit(&x, &y).unwrap();
902
903 let x_bad = Array2::from_shape_vec((2, 5), vec![1.0; 10]).unwrap();
904 let y_bad = array![0usize, 1];
905 assert!(fitted.partial_fit(&x_bad, &y_bad).is_err());
906 }
907
908 #[test]
909 fn test_complement_nb_class_prior() {
910 let (x, y) = make_count_data();
911 let model = ComplementNB::<f64>::new().with_class_prior(vec![0.5, 0.5]);
912 let fitted = model.fit(&x, &y).unwrap();
913 let preds = fitted.predict(&x).unwrap();
914 assert_eq!(preds.len(), 6);
915 }
916
917 #[test]
918 fn test_complement_nb_class_prior_wrong_length() {
919 let (x, y) = make_count_data();
920 let model = ComplementNB::<f64>::new().with_class_prior(vec![1.0]);
921 assert!(model.fit(&x, &y).is_err());
922 }
923
924 #[test]
925 fn test_complement_nb_three_classes() {
926 let x = Array2::from_shape_vec(
927 (9, 3),
928 vec![
929 5.0, 0.0, 0.0, 6.0, 0.0, 0.0, 4.0, 1.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0, 0.0, 1.0,
930 4.0, 0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0, 0.0, 1.0, 4.0,
931 ],
932 )
933 .unwrap();
934 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
935
936 let model = ComplementNB::<f64>::new();
937 let fitted = model.fit(&x, &y).unwrap();
938 assert_eq!(fitted.n_classes(), 3);
939 let preds = fitted.predict(&x).unwrap();
940 let correct = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
941 assert!(correct >= 7);
942 }
943
944 // sklearn 1.5.2 oracle fixture (R-CHAR-3) for the REQ-9a fitted accessors.
945 // X = [[5,1,0],[4,2,0],[6,0,1],[0,1,5],[1,0,4],[0,2,6]], y = [0,0,0,1,1,1].
946 fn oracle_xy() -> (Array2<f64>, Array1<usize>) {
947 let x = array![
948 [5.0, 1.0, 0.0],
949 [4.0, 2.0, 0.0],
950 [6.0, 0.0, 1.0],
951 [0.0, 1.0, 5.0],
952 [1.0, 0.0, 4.0],
953 [0.0, 2.0, 6.0],
954 ];
955 let y = array![0usize, 0, 0, 1, 1, 1];
956 (x, y)
957 }
958
959 #[test]
960 fn complement_feature_log_prob_and_count_match_sklearn() -> Result<(), FerroError> {
961 // sklearn ComplementNB().fit(X, y):
962 // feature_log_prob_ = [[2.3978952728, 1.7047480922, 0.3184537311],
963 // [0.3184537311, 1.7047480922, 2.3978952728]]
964 // feature_count_ = [[15, 3, 1], [1, 3, 15]]
965 let (x, y) = oracle_xy();
966 let fitted = ComplementNB::<f64>::new().fit(&x, &y)?;
967
968 let expected_flp = array![
969 [2.3978952728, 1.7047480922, 0.3184537311],
970 [0.3184537311, 1.7047480922, 2.3978952728],
971 ];
972 let flp = fitted.feature_log_prob();
973 assert_eq!(flp.dim(), (2, 3));
974 for ((i, j), &e) in expected_flp.indexed_iter() {
975 assert_relative_eq!(flp[[i, j]], e, epsilon = 1e-9);
976 }
977
978 let expected_fc = array![[15.0, 3.0, 1.0], [1.0, 3.0, 15.0]];
979 let fc = fitted.feature_count();
980 assert_eq!(fc.dim(), (2, 3));
981 for ((i, j), &e) in expected_fc.indexed_iter() {
982 assert_relative_eq!(fc[[i, j]], e, epsilon = 1e-9);
983 }
984 Ok(())
985 }
986
987 #[test]
988 #[allow(
989 clippy::approx_constant,
990 reason = "literal -0.6931471806 is the sklearn class_log_prior_ oracle value ln(0.5), not a use of the LN_2 constant"
991 )]
992 fn complement_feature_all_class_count_prior_match_sklearn() -> Result<(), FerroError> {
993 // sklearn ComplementNB().fit(X, y):
994 // feature_all_ = [16, 6, 16]
995 // class_count_ = [3, 3]
996 // class_log_prior_ = [-0.6931471806, -0.6931471806]
997 let (x, y) = oracle_xy();
998 let fitted = ComplementNB::<f64>::new().fit(&x, &y)?;
999
1000 let expected_fa = array![16.0, 6.0, 16.0];
1001 let fa = fitted.feature_all();
1002 assert_eq!(fa.len(), 3);
1003 for (i, &e) in expected_fa.iter().enumerate() {
1004 assert_relative_eq!(fa[i], e, epsilon = 1e-9);
1005 }
1006
1007 let expected_cc = array![3.0, 3.0];
1008 let cc = fitted.class_count();
1009 assert_eq!(cc.len(), 2);
1010 for (i, &e) in expected_cc.iter().enumerate() {
1011 assert_relative_eq!(cc[i], e, epsilon = 1e-9);
1012 }
1013
1014 let expected_clp = array![-0.6931471806, -0.6931471806];
1015 let clp = fitted.class_log_prior();
1016 assert_eq!(clp.len(), 2);
1017 for (i, &e) in expected_clp.iter().enumerate() {
1018 assert_relative_eq!(clp[i], e, epsilon = 1e-9);
1019 }
1020 Ok(())
1021 }
1022
1023 // The `PipelineEstimator` adapter must preserve the ORIGINAL float labels:
1024 // sklearn `ComplementNB.fit` sets `classes_ = np.unique(y)` and `predict`
1025 // returns `classes_[argmax(jll)]` — the original labels, NOT class indices
1026 // (`naive_bayes.py:103`). Live sklearn 1.5.2 oracle (run from /tmp):
1027 // X=[[3,0],[4,0],[0,3],[0,4]], y=[-1,-1,1,1], q=[[5,0],[0,5]]
1028 // ComplementNB().fit(X,y).classes_ -> [-1, 1]
1029 // ComplementNB().fit(X,y).predict(q) -> [-1, 1] (NOT [0, 1])
1030 #[test]
1031 fn complement_pipeline_preserves_original_float_labels() -> Result<(), FerroError> {
1032 let x = array![[3.0, 0.0], [4.0, 0.0], [0.0, 3.0], [0.0, 4.0]];
1033 let y = array![-1.0, -1.0, 1.0, 1.0];
1034 let f = ComplementNB::<f64>::new().fit_pipeline(&x, &y)?;
1035 let p = f.predict_pipeline(&array![[5.0, 0.0], [0.0, 5.0]])?;
1036 // Original labels [-1.0, 1.0], not the collapsed indices [0.0, 1.0].
1037 assert_eq!(p, array![-1.0, 1.0]);
1038 Ok(())
1039 }
1040}