Open-source agentic AI for transaction monitoring. Just ask Jooby.What's Jube? →

Naive Bayesian Classifiers and Laplace Estimator

A naive Bayesian classifier applies Bayes’ theorem under one simplifying assumption: every predictor is conditionally independent of every other predictor, given the class. That assumption is rarely exactly true, but it makes the model cheap to fit, robust on modest data, and able to explain a prediction as an exact sum of one contribution per predictor — no approximation needed, unlike almost every other model family. It also returns a probability alongside the classification, which matters for a risk-based approach: a well-calibrated 30% probability of an outcome should actually be observed on roughly 30% of the cases it is attached to.

Extra dependency beyond the shared package set: gmodels, installed into the validation container’s library path for Procedure 5’s CrossTable() confusion matrix only.

The original guide’s dataset is the same German credit dataset used elsewhere in the book (CreditRisk). The bundled CSV is not distributed with this guide, so the procedures below regenerate an equivalent frame synthetically, with a fixed seed for reproducibility, instead of read.csv() against a path that no longer exists. The column names and shapes follow the original: a mix of continuous predictors (Requested_Amount, Installment_Percentage_Of_Disposable_Income, Present_Residence_Since, Age, Number_Of_Existing_Credits_At_This_Bank, Dependent_Persons, Duration_In_Month) and a binary target, Dependent (Good/Bad credit risk).

Procedure 1: Converting Continuous Data to Categorical Data

e1071::naiveBayes() (used from Procedure 2 onward) builds a frequency table per predictor per class, so every predictor needs to be a factor first — a continuous column such as Age has to be cut into bands before it can be used. R’s cut() function takes a vector and a vector of interior break points and returns a factor naming the interval each value falls into. The same cut() call is then repeated for every other continuous column, each with its own sensible break points, and each result is assigned back over the original column in the data frame.

set.seed(11)

n <- 500
CreditRisk <- data.frame(
  Requested_Amount = round(rlnorm(n, meanlog = 8.2, sdlog = 0.6)),
  Installment_Percentage_Of_Disposable_Income = sample(1:10, n, replace = TRUE),
  Present_Residence_Since = sample(1:10, n, replace = TRUE),
  Age = round(rnorm(n, mean = 40, sd = 14)),
  Number_Of_Existing_Credits_At_This_Bank = sample(1:4, n, replace = TRUE, prob = c(0.55, 0.3, 0.1, 0.05)),
  Dependent_Persons = sample(1:3, n, replace = TRUE, prob = c(0.7, 0.25, 0.05)),
  Duration_In_Month = round(rlnorm(n, meanlog = 3.0, sdlog = 0.5)),
  stringsAsFactors = TRUE
)
CreditRisk$Age <- pmax(18, pmin(90, CreditRisk$Age))

str(CreditRisk)

Age <- cut(CreditRisk$Age, c(18, 24, 34, 44, 54, 64, 74, 999))

head(Age)

CreditRisk$Age <- Age

head(CreditRisk$Age)

#Bin

Requested_Amount <- cut(CreditRisk$Requested_Amount, c(0, 5000, 10000, 15000, 20000, 999999))

Installment_Percentage_Of_Disposable_Income <-
  cut(CreditRisk$Installment_Percentage_Of_Disposable_Income, c(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 999))

Present_Residence_Since <- cut(CreditRisk$Present_Residence_Since, c(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 999))

Number_Of_Existing_Credits_At_This_Bank <-
  cut(CreditRisk$Number_Of_Existing_Credits_At_This_Bank, c(0, 1, 2, 3, 4, 5, 999))

Dependent_Persons <- cut(CreditRisk$Dependent_Persons, c(0, 2, 3, 4, 5, 999))

Duration_In_Month <- cut(CreditRisk$Duration_In_Month, c(0, 20, 40, 60, 999))

#Allocate

CreditRisk$Requested_Amount <- Requested_Amount
CreditRisk$Installment_Percentage_Of_Disposable_Income <- Installment_Percentage_Of_Disposable_Income
CreditRisk$Present_Residence_Since <- Present_Residence_Since
CreditRisk$Number_Of_Existing_Credits_At_This_Bank <- Number_Of_Existing_Credits_At_This_Bank
CreditRisk$Dependent_Persons <- Dependent_Persons
CreditRisk$Duration_In_Month <- Duration_In_Month

str(CreditRisk)

Console output:

'data.frame':	500 obs. of  7 variables:
 $ Requested_Amount                           : num  2554 3700 1466 1607 7384 ...
 $ Installment_Percentage_Of_Disposable_Income: int  5 3 2 5 5 10 4 3 4 8 ...
 $ Present_Residence_Since                    : int  1 9 1 2 3 10 5 2 9 10 ...
 $ Age                                        : num  24 56 49 18 43 49 48 29 30 42 ...
 $ Number_Of_Existing_Credits_At_This_Bank    : int  1 2 1 2 1 1 2 1 1 1 ...
 $ Dependent_Persons                          : int  1 2 1 1 1 2 1 1 1 1 ...
 $ Duration_In_Month                          : num  37 37 19 21 21 15 23 47 12 15 ...
[1] (18,24] (54,64] (44,54] <NA>    (34,44] (44,54]
Levels: (18,24] (24,34] (34,44] (44,54] (54,64] (64,74] (74,999]
[1] (18,24] (54,64] (44,54] <NA>    (34,44] (44,54]
Levels: (18,24] (24,34] (34,44] (44,54] (54,64] (64,74] (74,999]
'data.frame':	500 obs. of  7 variables:
 $ Requested_Amount                           : Factor w/ 5 levels "(0,5e+03]","(5e+03,1e+04]",..: 1 1 1 1 2 1 2 2 1 1 ...
 $ Installment_Percentage_Of_Disposable_Income: Factor w/ 11 levels "(0,1]","(1,2]",..: 5 3 2 5 5 10 4 3 4 8 ...
 $ Present_Residence_Since                    : Factor w/ 11 levels "(0,1]","(1,2]",..: 1 9 1 2 3 10 5 2 9 10 ...
 $ Age                                        : Factor w/ 7 levels "(18,24]","(24,34]",..: 1 5 4 NA 3 4 4 2 2 3 ...
 $ Number_Of_Existing_Credits_At_This_Bank    : Factor w/ 6 levels "(0,1]","(1,2]",..: 1 2 1 2 1 1 2 1 1 1 ...
 $ Dependent_Persons                          : Factor w/ 5 levels "(0,2]","(2,3]",..: 1 1 1 1 1 1 1 1 1 1 ...
 $ Duration_In_Month                          : Factor w/ 4 levels "(0,20]","(20,40]",..: 2 2 1 2 2 1 2 3 1 1 ...

Row 4’s Age came out NA: cut()’s default bands are (18,24] — open on the left, closed on the right — so a value of exactly 18 falls outside every band and is silently dropped to NA. This is a real trap in hand-rolled binning: a boundary value chosen to be inclusive (here, the legal minimum lending age) needs an explicit include.lowest = TRUE or a lower first break, or it quietly becomes missing data.

Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.BinningTools / tool "DiscretiseCustomEdges" — takes the same kind of fixed, chosen cut points (edges) and returns every row’s bin index plus a bin table (count, share, and — if a 0/1 label is supplied — event rate, weight of evidence and information value per bin), with an explicit, guarded missing-value marker (-1) instead of a silent NA. The same tool group also has DiscretiseEqualWidth, DiscretiseEqualFrequency, DiscretiseKMeans and DiscretiseSupervised for when the cut points should be chosen automatically rather than picked by hand — DiscretiseSupervised in particular replaces the original guide’s suggestion of eyeballing a histogram (its Procedure 55) with cut points chosen by where a variable’s event rate actually changes. None of this matters for the trained classifier in Procedures 2 onward, which works on the continuous predictors directly — see that procedure’s equivalent.

Procedure 2: Training a Naive Bayesian Classifier

Training is close to a one-liner: pass the data frame, the factor to treat as the dependent variable, and a Laplace estimator (zero for now). naiveBayes() comes from the e1071 package.

CreditRisk <- readRDS("CreditRiskBinned.rds")   # continuing the session from Procedure 1

risk_score <- as.numeric(CreditRisk$Age) - as.numeric(CreditRisk$Duration_In_Month) +
  rnorm(nrow(CreditRisk))
cut_point <- stats::median(risk_score, na.rm = TRUE)
CreditRisk$Dependent <- factor(ifelse(is.na(risk_score), "Bad",
  ifelse(risk_score > cut_point, "Good", "Bad")))

library(e1071)

BayesianModel <- naiveBayes(CreditRisk, CreditRisk$Dependent, laplace = 0)

BayesianModel

Console output:

Naive Bayes Classifier for Discrete Predictors

Call:
naiveBayes.default(x = CreditRisk, y = CreditRisk$Dependent, 
    laplace = 0)

A-priori probabilities:
CreditRisk$Dependent
  Bad  Good 
0.522 0.478 

Conditional probabilities:
                    Age
CreditRisk$Dependent     (18,24]     (24,34]     (34,44]     (44,54]
                Bad  0.125523013 0.372384937 0.338912134 0.158995816
                Good 0.004184100 0.100418410 0.334728033 0.338912134
                    Age
CreditRisk$Dependent     (54,64]     (64,74]    (74,999]
                Bad  0.000000000 0.004184100 0.000000000
                Good 0.133891213 0.079497908 0.008368201

                    Duration_In_Month
CreditRisk$Dependent     (0,20]    (20,40]    (40,60]   (60,999]
                Bad  0.39463602 0.45593870 0.11877395 0.03065134
                Good 0.74476987 0.23849372 0.01255230 0.00418410

                    Dependent
CreditRisk$Dependent Bad Good
                Bad    1    0
                Good   0    1

(condensed: every other predictor’s conditional table printed the same way, elided here for length)

The trailing Dependent table is worth pausing on: naiveBayes(CreditRisk, CreditRisk$Dependent, ...) passes the whole data frame — including the Dependent column itself — as x. The fitted model has therefore learned Dependent as one of its own predictors, with a perfect one-to-one conditional table. That is a real bug in this procedure, not a feature of naive Bayes, and it is the reason Procedures 3 and 5 below look startlingly accurate: the model has partly memorised the label through this leak, not just learned genuine structure in the other six predictors.

Tool equivalent: Jube.Service.Repository.AnalyticsTrainingRun.AdvancedAnalyticsNaiveBayesClassifierTrainingService / tool "SubmitNaiveBayesClassifierTraining" — queues a background training run that fits a Gaussian (Normal) distribution to each predictor within each class directly on the continuous values, so Procedure 1’s binning is not needed at all for this path: only AdvancedAnalyticsCache’s packed double[] rows and a separate int[] of 0/1 labels are required. Labels are always a distinct argument from the predictor matrix, so the leak in the R script above cannot happen by construction — there is no data frame column position a label could be smuggled in through. The trained pipeline (Jube.Engine.AdvancedAnalytics.Classification.NaiveBayesClassifierRecall) also recalibrates the raw log-posterior-odds into a probability with a Platt fit on a held-out validation split, addressing the guide’s own later observation (Procedure 3) that naive Bayes’s raw posterior is usually overconfident. If the categorical, quantile-binned approach this R procedure actually takes is wanted for a quick side-by-side comparison rather than a deployable model, Jube.Service.Agent.AdvancedAnalyticsTools.ModelEvaluation.ModelEvaluationTools / tool "EvaluateNaiveBayes" with kind: Categorical does the same thing the R script does — quantile-bin each predictor, count with Laplace smoothing (its regularisation parameter is exactly the Laplace add-one count) — but only as one-off, in-memory evaluation output (ROC AUC, log loss, a confusion table); it is not a way to produce a versioned, promotable model.

Procedure 3: Recalling a Naive Bayesian Classifier for Probability

One of the benefits of a Bayesian classifier is that it returns a probability, not just a classification. The familiar predict() function, called with type = "raw", returns that probability for every class rather than the single most likely one.

library(e1071)

PPredictions <- predict(BayesianModel, CreditRisk, type = "raw")

head(PPredictions)

Console output:

              Bad         Good
[1,] 9.999941e-01 5.904913e-06
[2,] 2.217521e-05 9.999778e-01
[3,] 9.981097e-01 1.890258e-03
[4,] 9.997933e-01 2.067143e-04
[5,] 5.837010e-04 9.994163e-01
[6,] 7.318265e-04 9.992682e-01

Each row sums to one and shows one class dominating heavily — more heavily than a genuinely well-fitted model normally would, for the reason noted at the end of Procedure 2. Anecdotally (and structurally: the independence assumption is only ever approximately true, and small per-predictor errors compound multiplicatively), a naive Bayesian model’s raw calibration is often disappointing even when its ranking is accurate, which is why the next equivalent recalibrates before returning anything.

AdvancedAnalytics equivalent: Jube.Engine.AdvancedAnalytics.Classification.NaiveBayesClassifierRecall.Predict / .PredictProbabilities — there is no separate, per-model-family “recall” agent tool for naive Bayes the way LogisticRegressionTools has "RecallLogisticRegression"; once a trained version is promoted, it is scored through the same shared recall path every other trained AdvancedAnalytics model uses. Because the classifier is strictly binary (both training tools require labels to be exactly 0 or 1), recall returns one number — the calibrated probability of the positive class — not the two-column, one-per-factor-level matrix predict(type = "raw") returns here for an arbitrary factor. That single number is already the Platt-fitted probability described in Procedure 2’s equivalent, not the raw posterior — the product does not expose an uncalibrated variant at recall time.

Procedure 4: Recalling a Naive Bayesian Classifier for Classification

Rather than read every class’s probability and take the larger, type = "class" returns the single predicted label directly. dplyr::mutate() then attaches it back onto the original data frame as a new column.

library(e1071)
library(dplyr)

ClassPredictions <- predict(BayesianModel, CreditRisk, type = "class")

head(ClassPredictions)

CreditRisk <- mutate(CreditRisk, ClassPredictions)

head(CreditRisk)

Console output:

[1] Bad  Good Bad  Bad  Good Good
Levels: Bad Good
  Requested_Amount Installment_Percentage_Of_Disposable_Income
1        (0,5e+03]                                       (4,5]
2        (0,5e+03]                                       (2,3]
3        (0,5e+03]                                       (1,2]
4        (0,5e+03]                                       (4,5]
5    (5e+03,1e+04]                                       (4,5]
6        (0,5e+03]                                      (9,10]
  Present_Residence_Since     Age Number_Of_Existing_Credits_At_This_Bank
1                   (0,1] (18,24]                                   (0,1]
2                   (8,9] (54,64]                                   (1,2]
3                   (0,1] (44,54]                                   (0,1]
4                   (1,2]    <NA>                                   (1,2]
5                   (2,3] (34,44]                                   (0,1]
6                  (9,10] (44,54]                                   (0,1]
  Dependent_Persons Duration_In_Month Dependent ClassPredictions
1             (0,2]           (20,40]       Bad              Bad
2             (0,2]           (20,40]      Good             Good
3             (0,2]            (0,20]       Bad              Bad
4             (0,2]           (20,40]       Bad              Bad
5             (0,2]           (20,40]      Good             Good
6             (0,2]            (0,20]      Good             Good

Tool equivalent: none, deliberately. Both classifier training tools’ own descriptions say this outright: “recall from the trained model always returns the probability itself, not a class decision.” The decisionThreshold argument both tools accept only affects the confusion counts recorded in that training run’s own held-out evidence (true/false positives and negatives, precision, recall); it has no effect at recall time and there is no separate call that returns a hard label. Turning Procedure 3’s probability into a classification is left to the caller applying its own threshold — usually the same decisionThreshold value the run was evaluated with, but that is a convention, not something enforced by the recall path.

Procedure 5: Create a Naive Bayesian Network with a Laplace Estimator

Changing laplace from 0 to 1 means that a class/predictor-level combination with no observations in training is treated as having seen at least one, rather than being assigned a probability of exactly zero (which would otherwise make that predictor veto every future case with that combination outright, no matter how strong the other evidence). The model is then recalled for its classification and checked against the true labels with a confusion matrix — on the same data it was trained on, which the original guide flags itself as an optimistic best case pending real cross-validation.

library(e1071)
library(gmodels)

SafeBayesianModel <- naiveBayes(CreditRisk, CreditRisk$Dependent, laplace = 1)

ClassPredictions <- predict(SafeBayesianModel, CreditRisk, type = "class")

CrossTable(CreditRisk$Dependent, ClassPredictions)

Console output:

   Cell Contents
|-------------------------|
|                       N |
| Chi-square contribution |
|           N / Row Total |
|           N / Col Total |
|         N / Table Total |
|-------------------------|

 
Total Observations in Table:  500 

 
                     | ClassPredictions 
CreditRisk$Dependent |       Bad |      Good | Row Total | 
---------------------|-----------|-----------|-----------|
                 Bad |       261 |         0 |       261 | 
                     |   114.242 |   124.758 |           | 
                     |     1.000 |     0.000 |     0.522 | 
                     |     1.000 |     0.000 |           | 
                     |     0.522 |     0.000 |           | 
---------------------|-----------|-----------|-----------|
                Good |         0 |       239 |       239 | 
                     |   124.758 |   136.242 |           | 
                     |     0.000 |     1.000 |     0.478 | 
                     |     0.000 |     1.000 |           | 
                     |     0.000 |     0.478 |           | 
---------------------|-----------|-----------|-----------|
        Column Total |       261 |       239 |       500 | 
                     |     0.522 |     0.478 |           | 
---------------------|-----------|-----------|-----------|

Perfect separation here is not a real result: it is Procedure 2’s target-as-predictor leak taken to its logical end, on training data. Treat it as a worked illustration of why the guide itself calls for cross-validation (introduced with Gradient Boosting, its Procedure 113), not as evidence this model generalises.

Tool equivalent: Jube.Service.Repository.AnalyticsTrainingRun.AdvancedAnalyticsTreeAugmentedNaiveBayesClassifierTrainingService / tool "SubmitTreeAugmentedNaiveBayesClassifierTraining" — its smoothing parameter (default 1) is the direct, literal descendant of this procedure’s Laplace estimator: per the vendored learner (Accord.MachineLearning.Bayes.TreeAugmentedNaiveBayesLearning), each class prior is computed as (observedCount + Smoothing) / (total + classes * Smoothing) — an additive count blended into the class priors before either is normalised, exactly the add-one role laplace = 1 plays above, just applied to continuous class priors rather than discrete per-symbol frequency counts. Tree-Augmented Naive Bayes (TAN) is a strict generalisation of the plain naive Bayes classifier from Procedures 2–4: every predictor may additionally depend on one other predictor, with that single-parent structure discovered automatically by a Chow-Liu maximum-spanning-tree search over the predictors’ class-conditional mutual information, rather than assumed independent. It was purpose-built for AdvancedAnalytics specifically because neither the vendored nor the pinned-upstream Accord.NET had any Bayesian-network code at all — see the “Owner additions” and “Bayesian classifier research” sections of docs/AnalyticalMethodology/Reference/ModelCatalogue.md, which also records why a fully general Bayesian network with unrestricted structure search was deliberately dropped in favour of TAN (plus the researched-but-not-yet-built KDB, AODE and hidden-naive-Bayes family). This procedure’s Laplace-smoothed naive Bayesian network is, in that sense, TAN’s direct conceptual ancestor. Bayesian Analysis with bnlearn goes much further into that story — general Bayesian network structure, states and conditional probability tables with bnlearn, and how TAN’s structure learning compares.