The clearest way to feel the difference between writing rules and learning them is to do both on the same problem and compare. The problem is a support inbox of short messages, half of them unwanted.
import random
random.seed(11)
SPAM_TRAIN = [
'congratulations you have won a {prize}, claim it {when}',
'your {thing} will be closed, verify it {when}',
'earn {amount} a week from home, no experience needed',
'exclusive offer, {pct} off everything {when}',
'we noticed unusual activity on your {thing}, confirm it {when}',
'final notice about your {thing}, respond {when}',
'you have been selected for a {prize}, reply to arrange it',
'limited deal, {pct} discount if you order {when}',
'your {thing} is on hold, update your details {when}',
'claim your {prize} before it expires {when}',
]
SPAM_TEST = [
'act {when} to release the {amount} waiting on your {thing}',
'we tried to deliver your {prize}, reply with your details',
'a {pct} refund is pending, tell us where to send it',
'security alert on your {thing}, reply {when} or lose access',
]
HAM_TRAIN = [
'the invoice for {month} is attached, let me know if it looks wrong',
'can we move the {day} meeting, i have a clash',
'the report is finished, it is in the shared folder',
'thanks for the figures, they match the {thing} i have',
'i am out on {day}, send anything urgent before then',
'the training session is confirmed for {month}',
'could you check the {thing} settings when you get a moment',
'the client asked about dates, i said {month}',
'we should discuss the {pct} increase before {day}',
'their offer came in {when}, i think we should accept',
]
HAM_TEST = [
'payroll needs your {thing} details before {month}',
'the supplier quoted {amount}, which is over budget',
'reminder that the {day} review starts at nine',
'i have won the argument about the {month} deadline',
]
FILL = {
'prize': ['free holiday', 'new phone', 'cash prize', 'gift card'],
'when': ['today', 'within 24 hours', 'before friday', 'now'],
'thing': ['account', 'password', 'payment', 'subscription'],
'amount': ['500 pounds', '2000 pounds', '1000 pounds'],
'pct': ['90%', '75%', '80%'],
'month': ['january', 'march', 'october', 'june'],
'day': ['monday', 'tuesday', 'thursday', 'friday'],
}
NOISE = ['please', 'thanks', 'regards', 'best', 'hi', 'hello', 'team',
'quick', 'note', 'fyi', 'cheers', 'morning']
def fill(template):
out = template
for key, options in FILL.items():
while '{' + key + '}' in out:
out = out.replace('{' + key + '}', random.choice(options), 1)
return ' '.join([random.choice(NOISE)] + out.split()
+ [random.choice(NOISE)])
def build(spam_templates, ham_templates, n):
xs, ys = [], []
for _ in range(n):
xs.append(fill(random.choice(spam_templates)))
ys.append(1)
xs.append(fill(random.choice(ham_templates)))
ys.append(0)
return xs, ys
train_x, train_y = build(SPAM_TRAIN, HAM_TRAIN, 120)
test_x, test_y = build(SPAM_TEST, HAM_TEST, 60)
messages, labels = train_x + test_x, train_y + test_y
for message, label in list(zip(messages, labels))[:6]:
print('%-5s %s' % ('spam' if label else 'ok', message))
print()
print('%d messages, %d spam, %d for training and %d held back'
% (len(messages), sum(labels), len(train_x), len(test_x)))
spam note limited deal, 75% discount if you order now fyi
ok note thanks for the figures, they match the password i have quick
spam quick claim your new phone before it expires today hi
ok thanks the report is finished, it is in the shared folder note
spam cheers congratulations you have won a gift card, claim it now morning
ok fyi their offer came in within 24 hours, i think we should accept please
360 messages, 180 spam, 240 for training and 120 held back
How this set is held back matters as much as that it is
The obvious split is to shuffle the messages and keep a quarter back. Do that here and every model scores a perfect 1.000, because the held out messages are near duplicates of training ones and remembering is enough.
So the split is by phrasing instead. The training set uses ten templates a side and the held out set uses four completely different ones. That is the situation a real inbox presents: tomorrow's mail is not a reshuffle of today's, and the only interesting question is whether the system handles wording it has never seen.
The rules a sensible person would write
import random
random.seed(11)
SPAM_TRAIN = [
'congratulations you have won a {prize}, claim it {when}',
'your {thing} will be closed, verify it {when}',
'earn {amount} a week from home, no experience needed',
'exclusive offer, {pct} off everything {when}',
'we noticed unusual activity on your {thing}, confirm it {when}',
'final notice about your {thing}, respond {when}',
'you have been selected for a {prize}, reply to arrange it',
'limited deal, {pct} discount if you order {when}',
'your {thing} is on hold, update your details {when}',
'claim your {prize} before it expires {when}',
]
SPAM_TEST = [
'act {when} to release the {amount} waiting on your {thing}',
'we tried to deliver your {prize}, reply with your details',
'a {pct} refund is pending, tell us where to send it',
'security alert on your {thing}, reply {when} or lose access',
]
HAM_TRAIN = [
'the invoice for {month} is attached, let me know if it looks wrong',
'can we move the {day} meeting, i have a clash',
'the report is finished, it is in the shared folder',
'thanks for the figures, they match the {thing} i have',
'i am out on {day}, send anything urgent before then',
'the training session is confirmed for {month}',
'could you check the {thing} settings when you get a moment',
'the client asked about dates, i said {month}',
'we should discuss the {pct} increase before {day}',
'their offer came in {when}, i think we should accept',
]
HAM_TEST = [
'payroll needs your {thing} details before {month}',
'the supplier quoted {amount}, which is over budget',
'reminder that the {day} review starts at nine',
'i have won the argument about the {month} deadline',
]
FILL = {
'prize': ['free holiday', 'new phone', 'cash prize', 'gift card'],
'when': ['today', 'within 24 hours', 'before friday', 'now'],
'thing': ['account', 'password', 'payment', 'subscription'],
'amount': ['500 pounds', '2000 pounds', '1000 pounds'],
'pct': ['90%', '75%', '80%'],
'month': ['january', 'march', 'october', 'june'],
'day': ['monday', 'tuesday', 'thursday', 'friday'],
}
NOISE = ['please', 'thanks', 'regards', 'best', 'hi', 'hello', 'team',
'quick', 'note', 'fyi', 'cheers', 'morning']
def fill(template):
out = template
for key, options in FILL.items():
while '{' + key + '}' in out:
out = out.replace('{' + key + '}', random.choice(options), 1)
return ' '.join([random.choice(NOISE)] + out.split()
+ [random.choice(NOISE)])
def build(spam_templates, ham_templates, n):
xs, ys = [], []
for _ in range(n):
xs.append(fill(random.choice(spam_templates)))
ys.append(1)
xs.append(fill(random.choice(ham_templates)))
ys.append(0)
return xs, ys
train_x, train_y = build(SPAM_TRAIN, HAM_TRAIN, 120)
test_x, test_y = build(SPAM_TEST, HAM_TEST, 60)
messages, labels = train_x + test_x, train_y + test_y
BANNED = ['won', 'free', 'claim', 'offer', 'discount', 'urgent',
'verify', 'exclusive', 'selected', 'prize']
def rule_predict(message):
"""Somebody sat down and wrote what spam looks like."""
words = message.lower().replace(',', '').split()
return 1 if any(word in BANNED for word in words) else 0
def score(predict, xs, ys):
right = sum(1 for x, y in zip(xs, ys) if predict(x) == y)
caught = sum(1 for x, y in zip(xs, ys) if y == 1 and predict(x) == 1)
flagged = sum(1 for x, y in zip(xs, ys) if y == 0 and predict(x) == 1)
spam = sum(ys)
return right / len(xs), caught / spam, flagged / (len(ys) - spam)
acc, caught, wrongly = score(rule_predict, test_x, test_y)
print('%-28s %8s %8s %8s' % ('', 'accuracy', 'caught', 'false'))
print('%-28s %8.3f %8.3f %8.3f' % ('keyword rules', acc, caught, wrongly))
print()
print('spam the rules let through:')
shown = 0
for message, label in zip(test_x, test_y):
if label == 1 and rule_predict(message) == 0 and shown < 4:
print(' ' + message)
shown += 1
accuracy caught false
keyword rules 0.492 0.200 0.217
spam the rules let through:
fyi a 75% refund is pending, tell us where to send it morning
best a 90% refund is pending, tell us where to send it team
thanks a 75% refund is pending, tell us where to send it note
regards act before friday to release the 1000 pounds waiting on your account thanks
Ten sensible keywords, and on mail phrased in ways they were not written for they catch a fifth of the spam and score 0.492 overall, which is worse than tossing a coin. Read the messages they let through: every one is obviously unwanted to a human and contains none of the ten words.
This is not a contrived defeat. It is the actual dynamic of the problem, because whoever is sending the spam can read the filter too, and the phrasings that survive are exactly the ones that avoid the list. Each miss is an argument for adding another keyword, and that argument never ends. The rules also block ordinary mail: one of the held out messages is somebody saying they won an argument about a deadline.
The same problem, learned
import random
random.seed(11)
SPAM_TRAIN = [
'congratulations you have won a {prize}, claim it {when}',
'your {thing} will be closed, verify it {when}',
'earn {amount} a week from home, no experience needed',
'exclusive offer, {pct} off everything {when}',
'we noticed unusual activity on your {thing}, confirm it {when}',
'final notice about your {thing}, respond {when}',
'you have been selected for a {prize}, reply to arrange it',
'limited deal, {pct} discount if you order {when}',
'your {thing} is on hold, update your details {when}',
'claim your {prize} before it expires {when}',
]
SPAM_TEST = [
'act {when} to release the {amount} waiting on your {thing}',
'we tried to deliver your {prize}, reply with your details',
'a {pct} refund is pending, tell us where to send it',
'security alert on your {thing}, reply {when} or lose access',
]
HAM_TRAIN = [
'the invoice for {month} is attached, let me know if it looks wrong',
'can we move the {day} meeting, i have a clash',
'the report is finished, it is in the shared folder',
'thanks for the figures, they match the {thing} i have',
'i am out on {day}, send anything urgent before then',
'the training session is confirmed for {month}',
'could you check the {thing} settings when you get a moment',
'the client asked about dates, i said {month}',
'we should discuss the {pct} increase before {day}',
'their offer came in {when}, i think we should accept',
]
HAM_TEST = [
'payroll needs your {thing} details before {month}',
'the supplier quoted {amount}, which is over budget',
'reminder that the {day} review starts at nine',
'i have won the argument about the {month} deadline',
]
FILL = {
'prize': ['free holiday', 'new phone', 'cash prize', 'gift card'],
'when': ['today', 'within 24 hours', 'before friday', 'now'],
'thing': ['account', 'password', 'payment', 'subscription'],
'amount': ['500 pounds', '2000 pounds', '1000 pounds'],
'pct': ['90%', '75%', '80%'],
'month': ['january', 'march', 'october', 'june'],
'day': ['monday', 'tuesday', 'thursday', 'friday'],
}
NOISE = ['please', 'thanks', 'regards', 'best', 'hi', 'hello', 'team',
'quick', 'note', 'fyi', 'cheers', 'morning']
def fill(template):
out = template
for key, options in FILL.items():
while '{' + key + '}' in out:
out = out.replace('{' + key + '}', random.choice(options), 1)
return ' '.join([random.choice(NOISE)] + out.split()
+ [random.choice(NOISE)])
def build(spam_templates, ham_templates, n):
xs, ys = [], []
for _ in range(n):
xs.append(fill(random.choice(spam_templates)))
ys.append(1)
xs.append(fill(random.choice(ham_templates)))
ys.append(0)
return xs, ys
train_x, train_y = build(SPAM_TRAIN, HAM_TRAIN, 120)
test_x, test_y = build(SPAM_TEST, HAM_TEST, 60)
messages, labels = train_x + test_x, train_y + test_y
BANNED = ['won', 'free', 'claim', 'offer', 'discount', 'urgent',
'verify', 'exclusive', 'selected', 'prize']
def rule_predict(message):
"""Somebody sat down and wrote what spam looks like."""
words = message.lower().replace(',', '').split()
return 1 if any(word in BANNED for word in words) else 0
def score(predict, xs, ys):
right = sum(1 for x, y in zip(xs, ys) if predict(x) == y)
caught = sum(1 for x, y in zip(xs, ys) if y == 1 and predict(x) == 1)
flagged = sum(1 for x, y in zip(xs, ys) if y == 0 and predict(x) == 1)
spam = sum(ys)
return right / len(xs), caught / spam, flagged / (len(ys) - spam)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
model = make_pipeline(TfidfVectorizer(), LogisticRegression())
model.fit(train_x, train_y)
def learned_predict(message):
return int(model.predict([message])[0])
print('%-28s %8s %8s %8s' % ('', 'accuracy', 'caught', 'false'))
for name, fn in [('keyword rules', rule_predict),
('learned from examples', learned_predict)]:
acc, caught, wrongly = score(fn, test_x, test_y)
print('%-28s %8.3f %8.3f %8.3f' % (name, acc, caught, wrongly))
accuracy caught false
keyword rules 0.492 0.200 0.217
learned from examples 0.883 0.983 0.217
0.883 against 0.492, on wording neither system has seen. Nobody told the model that claim or congratulations matter. It was shown the training messages and worked out for itself which words carry signal and how much, which is a far more forgiving thing to carry into unfamiliar phrasing than a list of ten words that either appear or do not.
Note also what the model did not do. It scored 0.883, not 1.000. Unfamiliar wording costs it too, and a system that is honest about this is worth more than one that is only ever tested on mail like the mail it learned from.
And look at the last column, which is the one people skip. Both systems wrongly flag ordinary mail at exactly the same rate, 0.217. The model's entire advantage is in the middle column: it catches almost all of the spam where the rules catch a fifth, at no extra cost in blocked invoices. That is a much more precise claim than the model is better, and it is the kind of claim worth learning to make.
What the model decided was important
import random
random.seed(11)
SPAM_TRAIN = [
'congratulations you have won a {prize}, claim it {when}',
'your {thing} will be closed, verify it {when}',
'earn {amount} a week from home, no experience needed',
'exclusive offer, {pct} off everything {when}',
'we noticed unusual activity on your {thing}, confirm it {when}',
'final notice about your {thing}, respond {when}',
'you have been selected for a {prize}, reply to arrange it',
'limited deal, {pct} discount if you order {when}',
'your {thing} is on hold, update your details {when}',
'claim your {prize} before it expires {when}',
]
SPAM_TEST = [
'act {when} to release the {amount} waiting on your {thing}',
'we tried to deliver your {prize}, reply with your details',
'a {pct} refund is pending, tell us where to send it',
'security alert on your {thing}, reply {when} or lose access',
]
HAM_TRAIN = [
'the invoice for {month} is attached, let me know if it looks wrong',
'can we move the {day} meeting, i have a clash',
'the report is finished, it is in the shared folder',
'thanks for the figures, they match the {thing} i have',
'i am out on {day}, send anything urgent before then',
'the training session is confirmed for {month}',
'could you check the {thing} settings when you get a moment',
'the client asked about dates, i said {month}',
'we should discuss the {pct} increase before {day}',
'their offer came in {when}, i think we should accept',
]
HAM_TEST = [
'payroll needs your {thing} details before {month}',
'the supplier quoted {amount}, which is over budget',
'reminder that the {day} review starts at nine',
'i have won the argument about the {month} deadline',
]
FILL = {
'prize': ['free holiday', 'new phone', 'cash prize', 'gift card'],
'when': ['today', 'within 24 hours', 'before friday', 'now'],
'thing': ['account', 'password', 'payment', 'subscription'],
'amount': ['500 pounds', '2000 pounds', '1000 pounds'],
'pct': ['90%', '75%', '80%'],
'month': ['january', 'march', 'october', 'june'],
'day': ['monday', 'tuesday', 'thursday', 'friday'],
}
NOISE = ['please', 'thanks', 'regards', 'best', 'hi', 'hello', 'team',
'quick', 'note', 'fyi', 'cheers', 'morning']
def fill(template):
out = template
for key, options in FILL.items():
while '{' + key + '}' in out:
out = out.replace('{' + key + '}', random.choice(options), 1)
return ' '.join([random.choice(NOISE)] + out.split()
+ [random.choice(NOISE)])
def build(spam_templates, ham_templates, n):
xs, ys = [], []
for _ in range(n):
xs.append(fill(random.choice(spam_templates)))
ys.append(1)
xs.append(fill(random.choice(ham_templates)))
ys.append(0)
return xs, ys
train_x, train_y = build(SPAM_TRAIN, HAM_TRAIN, 120)
test_x, test_y = build(SPAM_TEST, HAM_TEST, 60)
messages, labels = train_x + test_x, train_y + test_y
BANNED = ['won', 'free', 'claim', 'offer', 'discount', 'urgent',
'verify', 'exclusive', 'selected', 'prize']
def rule_predict(message):
"""Somebody sat down and wrote what spam looks like."""
words = message.lower().replace(',', '').split()
return 1 if any(word in BANNED for word in words) else 0
def score(predict, xs, ys):
right = sum(1 for x, y in zip(xs, ys) if predict(x) == y)
caught = sum(1 for x, y in zip(xs, ys) if y == 1 and predict(x) == 1)
flagged = sum(1 for x, y in zip(xs, ys) if y == 0 and predict(x) == 1)
spam = sum(ys)
return right / len(xs), caught / spam, flagged / (len(ys) - spam)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
vec = TfidfVectorizer()
X = vec.fit_transform(train_x)
clf = LogisticRegression().fit(X, train_y)
words = vec.get_feature_names_out()
weights = clf.coef_[0]
order = weights.argsort()
print('%-16s %10s' % ('most spam-like', 'weight'))
for i in order[::-1][:6]:
print('%-16s %10.3f' % (words[i], weights[i]))
print()
print('%-16s %10s' % ('most ordinary', 'weight'))
for i in order[:6]:
print('%-16s %10.3f' % (words[i], weights[i]))
most spam-like weight
your 2.201
it 1.184
now 0.945
claim 0.926
today 0.895
order 0.775
most ordinary weight
the -2.881
we -1.165
should -1.163
in -1.003
is -0.854
meeting -0.766
That table is worth pausing on, because it shows the mechanism is not magic. The model learned a weight per word and adds them up. Some of those words you would have guessed. Others are there because of how this particular inbox is written, which is exactly the knowledge a person writing rules would have had to acquire by reading all two hundred messages.
And that is also the weakness
The model learned this inbox. Words that signal spam here may signal nothing elsewhere, and a spammer who reads the table can avoid every word in it. Rules have the same weakness and at least they are visible. A learned model's assumptions are implicit, which is why weeks 11 to 13 are about getting them back out.