Skip to main content

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}