This walkthrough builds an email spam detector one part at a time. It demonstrates binary classification: predicting which of two classes an input belongs to.
Problem: Classify an email as SPAM or NOT SPAM from five numerical features.
Dataset: Prepared email records containing message and sender characteristics plus a binary target.
| ML task | Binary classification |
| Level | Beginner |
| Dataset | 20,000 prepared email records |
| Inputs | Five numerical email and sender features |
| Target | is_spam - 0 for not spam, 1 for spam |
| Model | 5 -> 32 -> 16 -> 1 feed-forward neural network with a Sigmoid output |
| Default training | 50 CPU epochs |
| Related concepts | Machine Learning Basics, Neural Networks, Backpropagation, Deep Netts API, Model Development |
The model receives five email features:
num_links, num_words, has_offer, sender_score, all_caps
and predicts one binary target:
is_spam - 0 for NOT SPAM, 1 for SPAM
The complete flow is:
load -> inspect -> split -> scale -> build -> train -> evaluate -> save -> predict
Start with the dataset path, model path, and expected data shape:
private static final String DATASET_PATH =
"src/main/resources/datasets/spam_detection_dataset.csv";
private static final String MODEL_PATH =
"models/spam-classifier.dnet";
private static final int NUM_INPUTS = 5;
private static final int NUM_OUTPUTS = 1;
Five input columns describe each email. One output represents the probability that the email belongs to the spam class.
Verify Java and select deterministic CPU execution:
verifyJavaRuntime();
DeepNetts.getInstance().setUseCuda(false);
DeepNetts.getInstance().setMaxThreads(1);
Output:
WARNING: Using incubator modules: jdk.incubator.vector
The example requires Java 25 with the Vector API enabled. The dataset and network are small enough for CPU execution, and one worker thread keeps the run easy to reproduce.
Read the CSV dataset:
TabularDataSet<MLDataItem> dataSet =
DataSets.readCsv(
DATASET_PATH,
NUM_INPUTS,
NUM_OUTPUTS,
true,
","
);
The first five columns are features and the final is_spam column is the known target. These labels make this a supervised learning problem.
Print basic structural information:
System.out.println("Samples: " + dataSet.size());
System.out.println(
"Columns: " + Arrays.toString(dataSet.getColumnNames())
);
Output:
Samples: 20000 Columns: [num_links, num_words, has_offer, sender_score, all_caps, is_spam]
This confirms that the CSV was found and parsed. The expected columns are the five email features followed by is_spam.
Shuffle with a fixed seed and create training and test sets:
dataSet.shuffle(42);
TrainTestSplit split = dataSet.trainTestSplit(0.8);
DataSet<MLDataItem> trainingSet = split.getTrainingSet();
DataSet<MLDataItem> testSet = split.getTestSet();
The network learns from 80% of the samples. The remaining 20% measures how it handles emails that were not used to update its parameters.
Fit Min-Max scaling on the training set, then transform the test set with the same scaler:
MinMaxScaler scaler = DataSets.scaleToMinMax(trainingSet);
scaler.apply(testSet);
The features use different numerical ranges. Scaling prevents a large-valued feature such as num_words from dominating only because of its magnitude. The scaler must be fitted before touching the test data to avoid leakage.
Create the feed-forward architecture:
FeedForwardNetwork neuralNet =
FeedForwardNetwork.builder()
.addInputLayer(NUM_INPUTS)
.addFullyConnectedLayer(32)
.addFullyConnectedLayer(16)
.addOutputLayer(NUM_OUTPUTS, ActivationType.SIGMOID)
.lossFunction(LossType.CROSS_ENTROPY)
.randomSeed(42)
.build();
The architecture is:
5 -> 32 -> 16 -> 1
The hidden layers use ReLU by default and learn non-linear feature combinations. The Sigmoid output produces a value between 0 and 1, while Cross-Entropy supplies the classification training objective.
Set the main hyperparameters and train:
neuralNet.getTrainer()
.setStopEpochs(50)
.setLearningRate(0.001f)
.setOptimizer(OptimizerType.SGD)
.setBatchMode(true)
.setBatchSize(64)
.setShuffle(true);
neuralNet.train(trainingSet);
Output:
TRAINING NEURAL NETWORK ------------------------------------------------------------------------ Initial Train Error:1.3577209 Epoch:1, Time:242ms, TrainError:0.262803, TrainErrorChange:-1.0949179, TrainAccuracy:0.912625 Epoch:2, Time:156ms, TrainError:0.21086535, TrainErrorChange:-0.05193764, TrainAccuracy:0.917 Epoch:3, Time:120ms, TrainError:0.17802352, TrainErrorChange:-0.03284183, TrainAccuracy:0.923625 ... Epoch:50, Time:101ms, TrainError:0.118475, TrainErrorChange:-8.9000165E-4, TrainAccuracy:0.955625 TRAINING COMPLETED Total Training Time: 6212ms ------------------------------------------------------------------------
Mini-batch mode updates the model using groups of 64 samples. The middle epochs are omitted for readability. Across all 50 epochs, SGD reduced training error from about 1.36 to 0.12 and reached about 95.6% training accuracy.
Test the trained network on unseen samples:
ClassificationMetrics metrics = (ClassificationMetrics) neuralNet.test(testSet);
System.out.println(metrics);
Output:
Class: is_spam Total items: 3999 True positive:261.0 Number of examples correctly classified as positive True negative:3576.0 Number of examples correctly classified as negative False positive:76.0 Number of examples incorrectly classified as positive False negative:86.0 Number of examples incorrectly classified as negative Accuracy (ACC): 0.9594899 How often is a classifier correct in total (percent of correct classifications) Precision (PPV): 0.7744807 How often is a classifier correct when it gives positive prediction Recall: 0.7521614 When it is actually positive class, how often does it give positive prediction F1 Score: 0.7631579 Harmonic average (balance) of precision and recall Specificity (TNR): 0.97918946 When it is actually negative class, how often does it give negative prediction Fall-out (FPR): 0.020810515 How often it gives false positive prediction in total (percent of false positive predictions) False negative rate (FNR): 0.24783862 How often it gives false negative prediction in total (percent of false negative predictions)
The classifier reached about 95.9% accuracy, but the confusion matrix adds important context: it incorrectly marked 76 legitimate emails as spam and missed 86 spam emails. Precision, recall, and F1 therefore give a more complete picture than accuracy alone.
Attach preprocessing before saving the trained network:
neuralNet.setNormalizer(scaler);
Files.createDirectories(Path.of("models"));
neuralNet.save(MODEL_PATH);
New emails must be scaled exactly like the training samples. Saving the scaler with the model keeps that rule inside the reusable prediction pipeline.
Create an input array in the same order as the dataset columns, then predict its spam probability and class:
float[] email = {
4, // num_links
55, // num_words
1, // has_offer
0.35f, // sender_score
1 // all_caps
};
float spamProbability = neuralNet.predict(email)[0];
System.out.printf("Spam probability: %.4f%n", spamProbability);
System.out.println(spamProbability >= 0.5f ? "SPAM" : "NOT SPAM");
Output:
Spam probability: 0.9259 SPAM
Each number describes one email characteristic, so the feature order must
match training. The probability is above the 0.5 threshold and produces the
final SPAM decision.
Pass this option to the Java process through your IDE's application run configuration or the command line:
--add-modules=jdk.incubator.vector
You assembled a complete binary-classification pipeline:
Labeled email CSV
|
v
80/20 train/test split
|
v
Min-Max scaling
|
v
5 -> 32 -> 16 -> 1 network
|
v
Training and classification evaluation
|
v
Saved model and email classification
The Sigmoid output estimates confidence that the supplied email features belong to the spam class.
The example converts the probability into a binary decision by treating values at or above 0.5 as spam.
Was this helpful?
Thank you!