Read a spam rule, train a small filter, and choose which mistake costs more
classification
spam detection
workforce research
Learn how spam detection turns text into decisions while keeping false alarms and missed scams separate.
Maya opens the Riverton Workforce Lab inbox before the morning appointments. Most messages ask ordinary questions about applications, passwords, and classes. Mixed into the same list are offers that ask jobseekers to send money.
If the Lab blocks too much, a real request can disappear. If it blocks too little, a scam can reach someone who is looking for work. A spam filter is a text classifier, a tool that assigns a label to a piece of writing.
Note
The Riverton Workforce Lab, its job board, and its training flyer are fictional and were created for teaching.
A dataset written by the same person who writes the classifier cannot measure whether the method works. This invented inbox checks that the code runs and makes the trade-off visible; it does not estimate performance for real spam filters.
TipWhat you will learn
By the end of this lesson, you will be able to:
read a transparent spam rule;
train a small text classifier;
compare the rule and model where they disagree;
read a confusion matrix; and
explain how a decision threshold trades false alarms against missed scams.
Load the invented inbox
The inbox file was generated once by data-raw/build-riverton-inbox.R and then committed. The lesson reads that file; it does not write examples while the page renders. readr opens the CSV files, dplyr and tidyr shape tables, stringr reads patterns, ggplot2 draws the chart, tidymodels fits the classifier, textrecipes turns words into features, glmnet fits the model, and digest checks the file.
The file has 60 invented messages: 44 genuine messages and 16 job scams. Those counts describe the fixture only. They do not say how much scam traffic reaches a workforce office.
Hand-written fixtures can fail before any model runs. The classes may separate on grammar rather than on the intended idea. A superficial feature is a cue such as punctuation or pronoun use that can predict a label without reading the substance of the message. The check below tests a bad shortcut: mark a message as spam when it has no first-person pronoun and does not end with a question mark.
A grammar-only shortcut no longer solves the invented inbox
Shortcut
Accuracy
Scam recall
Genuine recall
No first-person pronoun and no terminal question mark
0.6667
0.3125
0.7955
This shortcut scores 0.6667 accuracy and 0.3125 scam recall. That weak result is the point. When you write examples yourself, the two classes can separate on a pattern you did not mean to teach. The first check on any hand-built dataset is whether a stupid feature already solves it.
One shortcut is not the whole audit, so the next chunk sweeps several. For each feature it tries both orientations, marking a message as a scam when the feature is present and again when it is absent, and keeps whichever scores better. That is deliberately generous to the shortcut, because the question is whether any cheap cue could substitute for reading.
superficial_features <- inbox |>transmute( is_spam,`first-person pronoun`=str_detect(text, first_person_pattern),`ends with a question mark`=str_detect(text, "\\?\\s*$"),`longer than the median`=str_count(text, "\\S+") >=median(str_count(text, "\\S+")),`exclamation mark`=str_detect(text, "!"),`contains a digit`=str_detect(text, "[0-9]") ) |>pivot_longer(-is_spam, names_to ="feature", values_to ="present")shortcut_sweep <- superficial_features |>reframe(spam_when_feature_is =c("present", "absent"),accuracy =c(mean(present == is_spam),mean(present != is_spam) ),scam_recall =c(mean(present[is_spam]),mean(!present[is_spam]) ),.by = feature ) |>arrange(feature, desc(accuracy), desc(scam_recall)) |>slice_head(n =1, by = feature) |>arrange(desc(accuracy), feature)shortcut_display <-bind_rows( shortcut_sweep,tibble(feature ="always genuine baseline",spam_when_feature_is ="never",accuracy =mean(!inbox$is_spam),scam_recall =0 )) |>arrange(desc(accuracy), feature)length_feature_counts <- superficial_features |>filter(feature =="longer than the median") |>summarise(scams_above_median =sum(present & is_spam),genuine_above_median =sum(present &!is_spam) )knitr::kable( shortcut_display |>mutate(across(where(is.numeric), \(value) round(value, 4))),col.names =c("Superficial feature","Predict spam when feature is","Accuracy","Scam recall" ),caption ="Each shortcut uses one orientation and is compared with the majority baseline",row.names =FALSE)
Each shortcut uses one orientation and is compared with the majority baseline
Superficial feature
Predict spam when feature is
Accuracy
Scam recall
exclamation mark
present
0.7667
0.1250
always genuine baseline
never
0.7333
0.0000
contains a digit
present
0.7333
0.1250
first-person pronoun
absent
0.6000
0.3125
longer than the median
present
0.5833
0.8125
ends with a question mark
present
0.5167
0.1875
No cue improves accuracy much beyond always predicting genuine. Exclamation marks improve accuracy by two messages, but catch only 2 of 16 scams. The digit cue exactly matches the 0.7333 majority baseline. Message length exposes another fixture artifact: 13 of 16 scams, but also 22 of 44 genuine messages, are longer than the median. That cue catches many scams while producing too many false alarms to classify well. These are diagnostics of how the hand-written file was made, not evidence that any cue will transfer. The way to find out is to test text that did not shape the examples or the rule.
The build script for this file recomputes the orientation-preserving sweep before writing the CSV and refuses to write if any single feature reaches 0.80 accuracy or 0.95 scam recall.
Start with a rule a person can read
A rule-based spam filter searches for known warning signs. This one marks a message when it mentions fees, gift cards, crypto payments, bank details, guaranteed placement, deposits, or similar language. The rule is easy to inspect, which also means it is easy for a scammer to route around.
The rule marks 14 of the 16 invented scams and also blocks two genuine messages. It misses two scams. In this fixture, the easy cases are the ones that say fee, deposit, or crypto plainly.
The word list was written after reading these messages. Eleven of its 18 terms match exactly one message, so the full 60-row table is a lookup table scored on the same rows that shaped it. The two blocked genuine messages were written with scam vocabulary on purpose to show that a readable rule can fail on reports about scams. The comparison below applies both methods to the same 19 rows, but that does not make the rule out-of-sample: its terms were chosen after reading all 60 messages, including those 19.
Train a small filter
A trained model learns word weights from labelled examples. The seed for the train-test split is 4801, and the seed before fitting is 4802. The split is stratified, meaning each side keeps both recorded classes.
Rule-based spam filter on the same invented test split
Rule prediction
Recorded label
Messages
spam
spam
3
genuine
spam
2
spam
genuine
1
genuine
genuine
13
knitr::kable(as.data.frame(model_confusion$table),col.names =c("Model prediction", "Recorded label", "Messages"),caption ="Model confusion matrix on the invented test split",row.names =FALSE)
Model confusion matrix on the invented test split
Model prediction
Recorded label
Messages
spam
spam
3
genuine
spam
2
spam
genuine
3
genuine
genuine
11
knitr::kable( test_comparison |>mutate(accuracy =round(accuracy, 4)),col.names =c("Method", "Correct", "Rows", "Accuracy", "Evidence status"),caption ="The model is compared with its same-split majority baseline",row.names =FALSE)
The model is compared with its same-split majority baseline
Method
Correct
Rows
Accuracy
Evidence status
Readable rule
16
19
0.8421
in-sample: terms came from all 60 messages
Trained model
14
19
0.7368
out-of-sample for the fitted model
Always genuine
14
19
0.7368
not fitted
On the same 19 rows, the rule labels 3 of the 5 invented scams as spam and blocks one genuine message. Its 0.8421 accuracy is in-sample because the terms were written after reading all 60 messages. The model also catches 3 scams, but blocks three genuine messages. Its out-of-sample accuracy is 0.7368, exactly the always-genuine baseline on this split. The rule and model therefore cannot be ranked as held-out competitors. The table does show why a score needs a baseline: without one, a fitted model can look informative while matching a trivial rule.
Look at the disagreements
The rule and the model do not mark the same messages. Disagreements are useful because a person can inspect a short list and ask which tool failed in a way that matters.
disagreements <- spam_scored |>filter(rule_spam != model_spam) |>arrange(message_id) |>mutate(recorded_label =if_else(is_spam, "spam", "genuine"),spam_probability =round(spam_probability, 3) ) |>select( message_id, recorded_label, spam_probability, rule_spam, model_spam, text )knitr::kable( disagreements,col.names =c("Message","Recorded label","Model spam score","Rule says spam","Model says spam","Text" ),caption ="Where the readable rule and the trained model disagree",row.names =FALSE)
Where the readable rule and the trained model disagree
Message
Recorded label
Model spam score
Rule says spam
Model says spam
Text
M004
genuine
0.972
FALSE
TRUE
Please confirm that my documents for the youth internship reached your office.
M030
genuine
0.869
FALSE
TRUE
Can I apply to the data support certificate with a GED?
M034
genuine
0.010
TRUE
FALSE
A listing says paid training, but the attachment asks for my bank password.
M046
genuine
0.998
FALSE
TRUE
Could you call with bus directions to the skills centre?
The message about documents reaching the office looks genuine to the recorded label but receives a high model score. The message about a bank password is genuine because it reports a suspicious listing, yet the rule catches the word bank. The two callback and eligibility messages show the model’s own false alarms. Those are different kinds of errors.
Move the cut point
The model produces a score between 0 and 1. The cut point turns that score into a decision. A lower cut point catches more possible scams and risks blocking more genuine messages. A higher cut point does the reverse.
This curve uses the same 19 rows as the model table. Choosing a cut point from the curve and then reporting its errors on these rows would spend the test set twice. A deployed threshold should be chosen on separate calibration data before the final test.
Figure 2: The spam cut point shifts false alarms first, then missed scams, on the invented test split.
For this invented split, thresholds from 0.30 through 0.85 have the same error counts: three genuine messages blocked and two scams missed. Only the low and high ends move the counts. That is a number about this fixture, not about the world. A company inbox might prefer to block anything suspicious. A workforce inbox serving jobseekers may need a review queue because deleting a genuine posting can cost someone an opportunity, while letting a scam through can cost money or personal information.
Real spam also adapts. A static test set cannot measure what happens after senders learn the filter’s habits and change their wording.
What to remember
Spam detection is text classification with unequal errors.
A readable rule helps inspection, but it is brittle.
A trained model still needs a human-chosen threshold.
Every score needs a baseline and a named test set.
The Riverton inbox proves only that the lesson code runs on an invented file.