---
title: "NaiveBayes models"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{NaiveBayes models}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
if (
  requireNamespace("klaR", quietly = TRUE) &&
    requireNamespace("naivebayes", quietly = TRUE)
) {
  library(tidypredict)
  library(dplyr)
  eval_code <- TRUE
} else {
  eval_code <- FALSE
}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  eval = eval_code
)
```

| Function                                                      |Works|
|---------------------------------------------------------------|-----|
|`tidypredict_fit()`, `tidypredict_sql()`, `parse_model()`      |  ✔  |
|`tidypredict_to_column()`                                      |  ✗  |
|`tidypredict_test()`                                           |  ✗  |
|`tidypredict_interval()`, `tidypredict_sql_interval()`         |  ✗  |
|`parsnip`                                                      |  ✔  |

`klaR::NaiveBayes()` and `naivebayes::naive_bayes()` fit naive Bayes
classifiers. Predicting with such a model
multiplies the prior probability of each class by one conditional density per
predictor, and then normalizes those products into posterior probabilities.
Working on the log scale turns the products into sums, which makes the posterior
probabilities the softmax of the summed log densities.

Numeric predictors contribute a normal log density, and categorical predictors
contribute a `case_when()` lookup of the conditional probability of the observed
level. `naivebayes::naive_bayes()` additionally supports Poisson densities for
integer predictors when fit with `usepoisson = TRUE`. Kernel density estimates
cannot be expressed this way, so models fit with `usekernel = TRUE` are not
supported.

Because these models predict one probability per outcome class,
`tidypredict_fit()` returns a *named list* of expressions, one for each class,
rather than a single expression. Since the output is a list,
`tidypredict_to_column()` and `tidypredict_test()` are not supported.

## `tidypredict_` functions

```{r}
model <- klaR::NaiveBayes(Species ~ ., data = iris)
```

- Create the R formulas, one per class
    ```{r}
fit <- tidypredict_fit(model)
names(fit)
fit[["setosa"]]
    ```

- Add the predictions to the original table
    ```{r}
library(dplyr)

iris %>%
  mutate(!!!tidypredict_fit(model)) %>%
  glimpse()
    ```

- Confirm that the results match the model's `predict()` results
    ```{r}
probs <- sapply(fit, \(f) rlang::eval_tidy(f, iris))
all.equal(unname(probs), unname(predict(model, iris)$posterior))
    ```

### Extreme values of a numeric predictor

There is one case where `tidypredict_fit()` does not reproduce `predict()`, and
it is deliberately left in place.

Both packages evaluate each normal density and then replace any density that
came out as exactly zero with their `threshold` argument, `0.001` by default. A
normal density only reaches zero by underflowing the smallest number a double
can hold, which takes a value roughly 38 standard deviations from that class's
mean. `tidypredict` works on the log scale throughout, where such a value is an
ordinary negative number and nothing underflows, so the substitution never
happens.

The substitution is drastic when it fires. Consider an iris with a
`Sepal.Length` of 20, well beyond the largest in the data:

```{r}
outlier <- iris[1, ]
outlier$Sepal.Length <- 20

predict(model, outlier)$posterior
```

`setosa` has the narrowest spread of the three classes, so its density is the
first to underflow. Replacing it with `0.001` makes it enormous next to the
other two, which are around `1e-162` and `1e-97` but were computed honestly, so
the class that fits the value worst is the one that wins. This happens silently
unless the value is extreme enough for *every* class to underflow, which is the
only case either package warns about. `tidypredict` instead continues on the
log scale and reports the ordering the fitted model implies:

```{r}
sapply(tidypredict_fit(model), \(f) rlang::eval_tidy(f, outlier))
```

Reproducing the substitution is not possible on the log scale in any case.
Testing `exp(log_density) == 0` instead of `density == 0` picks out a different
set of values: over 200,000 draws from the band where the underflow happens the
two tests disagree on about 1,000 of them, because the two routes round
differently in the denormal range. A SQL backend, working in its own floating
point, would disagree again in a third way.

## parsnip

`parsnip` fitted models are also supported by `tidypredict`:
```{r}
library(parsnip)
library(discrim)

p_model <- naive_Bayes() %>%
  set_engine("klaR", usekernel = FALSE) %>%
  fit(Species ~ ., data = iris)
```

```{r}
tidypredict_fit(p_model)[["virginica"]]
```

## `naivebayes::naive_bayes()`

The `naivebayes` package is supported in the same way:
```{r}
nb_model <- naivebayes::naive_bayes(Species ~ ., data = iris)

nb_fit <- tidypredict_fit(nb_model)
nb_fit[["setosa"]]
```

```{r}
nb_probs <- sapply(nb_fit, \(f) rlang::eval_tidy(f, iris))
all.equal(
  unname(nb_probs),
  unname(predict(nb_model, iris[names(nb_model$tables)], type = "prob"))
)
```

The `"naivebayes"` parsnip engine works too, as long as `usekernel = FALSE`:
```{r}
nb_p_model <- naive_Bayes() %>%
  set_engine("naivebayes", usekernel = FALSE) %>%
  fit(Species ~ ., data = iris)

tidypredict_fit(nb_p_model)[["virginica"]]
```

## Parse model spec

Here is an example of the model spec:
```{r}
pm <- parse_model(model)
str(pm, 2)
```
