Skip to content

Fix NaiveBayes prediction paths and prior dtype - #359

Merged
josevalim merged 5 commits into
elixir-nx:mainfrom
RicardoSantos-99:fix-naive-bayes-predict-bugs
Aug 23, 2026
Merged

Fix NaiveBayes prediction paths and prior dtype#359
josevalim merged 5 commits into
elixir-nx:mainfrom
RicardoSantos-99:fix-naive-bayes-predict-bugs

Conversation

@RicardoSantos-99

Copy link
Copy Markdown
Contributor

Five bugs in the NaiveBayes modules, four on the prediction path.

  • BernoulliNB never applies :binarize at predict time. The threshold is not kept on the model, so every predict* passes raw x into a likelihood that assumes binary features. :binarize defaults to 0.0, so this hits the default configuration.
  • CategoricalNB's check_dim reads axis 1 of feature_count, which is the class count here (the tensor is {num_features, num_classes, num_categories}, not 2-D as in the other modules). It rejects valid input and accepts invalid input whenever num_features != num_classes.
  • CategoricalNB's joint_log_likelihood sizes its accumulator from x rather than from the class axis, so predict raises for any dataset where the two differ. Every doctest in the module uses num_features == num_classes, the one shape where this is invisible.
  • CategoricalNB's :min_categories is dead code. Keyword.pop rebinds opts before the value is read, so the category count always falls back to being inferred from the data.
  • class_log_priors is computed in f32 regardless of input type, while class_count and feature_log_probability follow the input. The block is identical in all four modules, so all four are fixed.

:alpha also gets a non-negative check through the existing Scholar.Options.non_negative_number. A negative value previously produced NaN with no error.

Behaviour changes: %Bernoulli{} gains a :binarize field; BernoulliNB predictions change for input that is not already binary, and its doctests are updated; fit/3 raises on a negative :alpha; CategoricalNB models built with :min_categories change shape.

fit stores the binarize threshold and applies it before counting
features, but predict/predict_log_probability/predict_probability/
predict_joint_log_probability passed x straight to the likelihood
computation. Any input that wasn't already 0/1 (the common case, since
binarize defaults to 0.0 specifically so raw counts can be passed in)
produced wrong log-likelihoods, since the formula assumes binary x.

Stores binarize on the struct as a plain (non-tensor) field and applies
it once, centrally, in joint_log_likelihood, so every predict path
gets it. Verified against sklearn.naive_bayes.BernoulliNB on the same
data: predict_log_probability/predict_probability now match sklearn
exactly (previously off by orders of magnitude), and
predict_joint_log_probability matches sklearn's internal
_joint_log_likelihood computed on the properly-binarized input.
…tion

check_dim compared x's feature count against
Nx.axis_size(model.feature_count, 1), but feature_count here is
{num_features, num_classes, num_categories} (unlike the 2D
{num_classes, num_features} in the other NaiveBayes modules this was
copied from), so axis 1 is num_classes. Every predict/*_probability
call rejected correctly-shaped input whenever num_features didn't
happen to equal num_classes, and would silently accept wrongly-shaped
input in the reverse case. Fixed to read axis 0.

Separately, fit's min_categories option was dead: Keyword.pop removed
:min_categories from opts and rebound it, so the very next read of
opts[:min_categories] (used to size the feature_count/
feature_log_probability tensors) always saw nil and silently fell back
to inferring the category count from the training data alone. Kept a
flag captured before the pop and branched on it instead, matching
sklearn.naive_bayes.CategoricalNB's min_categories semantics (a literal
per-feature category count, not a max index) - verified shapes match
sklearn exactly for both list and tensor min_categories inputs.
joint_log_likelihood sized its accumulator from x, as
{num_samples, num_features}, but each loop iteration contributes a
{num_samples, num_classes} term (feature_log_probability is
{num_features, num_classes, num_categories}, so the class axis is 1).
The two only agree when num_features happens to equal num_classes,
which is the case in every existing doctest and test - x is
Nx.iota({4, 3}) with num_classes: 3 - so the whole predict path was
only ever exercised in the one shape where the bug is invisible. Any
other dataset raised "cannot broadcast tensor of dimensions {n, c} to
{n, f}".

Same num_features/num_classes confusion as the check_dim bug. Also
pins the squeeze to axis 0 so a model with a single class or a single
category does not lose an extra axis.

Verified against sklearn.naive_bayes.CategoricalNB on a 3-feature,
2-class dataset across default/min_categories/class_prior/fit_prior:
predictions identical and probabilities within f32 precision.
class_log_priors was computed in f32 regardless of the input type on
two of its three branches, while class_count and
feature_log_probability followed the input, so an f64 model silently
carried an f32 prior into every joint log-likelihood:

  * explicit priors: class_priors was built with Nx.tensor/1 without
    type:, unlike alpha and sample_weights next to it, which both pass
    type: to_float_type(x).
  * fit_priors: false: Nx.log/1 on the bare num_classes integer
    defaults to f32.

Multiplying a typed literal by num_classes keeps the log in the target
type rather than widening an f32 result afterwards, which would keep
f32 precision. Verified that -log(3) and log(0.3) now come back
bit-exact against the f64 reference (previously off by ~2e-8), and
that class_log_priors matches feature_log_probability's type across
all three prior branches, both backends.

Applies to all four modules since the block is identical in each.
Each test was checked against the pre-fix code: seven of them fail
there and pass here, so they detect the bugs rather than merely
documenting current behaviour. Expected values come from
sklearn.naive_bayes, not from Scholar's own output.
@RicardoSantos-99
RicardoSantos-99 force-pushed the fix-naive-bayes-predict-bugs branch from 2a73fb0 to 54889c1 Compare August 23, 2026 20:55
@josevalim
josevalim merged commit e737089 into elixir-nx:main Aug 23, 2026
2 checks passed
@josevalim

Copy link
Copy Markdown
Contributor

💚 💙 💜 💛 ❤️

@RicardoSantos-99
RicardoSantos-99 deleted the fix-naive-bayes-predict-bugs branch August 23, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants