Feature Engineering · Notebook 6 companion articleFeature Engineering for Text Data
Every earlier notebook started with a table someone had already built: rows, columns and a starting point for deciding which columns matter. A raw SMS message has none of that. There's no column for how urgent it sounds. There are no columns at all, just a string of characters. Every number in this notebook has to be created from that text, and then justified.
Start hereIntroduction#
Notebook 5 unpacked one timestamp into a dozen features. This notebook does the same with a sentence, at a much bigger scale: one text column can become thousands of columns. Several earlier ideas come back with a text twist. Words the model has never seen are notebook 4's unseen categories again. The hashing trick from notebook 4 returns, and so does the leakage rule.
This notebook is about turning text into a table of numbers, and deciding which numbers are worth creating. The main dataset is the SMS Spam Collection: 5,574 real text messages labelled ham (normal) or spam. The two classes differ in both word choice and writing style, so we can fairly compare those two ideas instead of assuming one wins.
A second, smaller dataset of 3,000 labelled review sentences from Amazon, IMDB and Yelp joins later, to answer one question spam detection can't.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import re
from sklearn.feature_extraction.text import (
CountVectorizer, TfidfVectorizer, HashingVectorizer, ENGLISH_STOP_WORDS
)
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import (
cross_val_score, StratifiedKFold, RepeatedStratifiedKFold, train_test_split
)
from sklearn.pipeline import Pipeline, FeatureUnion
from sklearn.preprocessing import StandardScaler
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix
from sklearn.utils.murmurhash import murmurhash3_bytes_s32
plt.style.use('seaborn-v0_8-whitegrid')
pd.set_option('display.width', 200)
pd.set_option('display.max_columns', 50)
# One split object reused everywhere so every score in this notebook is comparable.
CV5 = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
print('Ready.')
Ready.
sms = pd.read_csv('datasets/sms_spam.csv')
sms['is_spam'] = (sms['label'] == 'spam').astype(int)
print('shape:', sms.shape)
print()
print(sms['label'].value_counts())
print()
print(f"spam rate: {sms['is_spam'].mean():.4f}")
print()
sms.head(5)
shape: (5574, 3) label ham 4827 spam 747 Name: count, dtype: int64 spam rate: 0.1340
label message is_spam 0 ham Go until jurong point, crazy.. Available only ... 0 1 ham Ok lar... Joking wif u oni... 0 2 spam Free entry in 2 a wkly comp to win FA Cup fina... 1 3 ham U dun say so early hor... U c already then say... 0 4 ham Nah I don't think he goes to usf, he lives aro... 0
for lab in ['ham', 'spam']:
print(f'--- {lab.upper()} ---')
for m in sms.loc[sms['label'] == lab, 'message'].head(3):
print(' ', m[:110])
print()
--- HAM --- Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat.. Ok lar... Joking wif u oni... U dun say so early hor... U c already then say... --- SPAM --- Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question FreeMsg Hey there darling it's been 3 week's now and no word back! I'd like some fun you up for it still? Tb o WINNER!! As a valued network customer you have been selected to receivea £900 prize reward! To claim call 0906
Even before any modeling, you can see a pattern. Ham is short, lowercase and chatty. Spam is long, loud and full of numbers, prices and short codes. Remember this, because section 8 turns it into nine columns that beat a vocabulary of thousands of words on F1.
First, the metric. The classes are very unbalanced, and that changes what "good" means.
majority = 1 - sms['is_spam'].mean()
print(f'Always-predict-ham accuracy: {majority:.4f}')
print()
print(f'A model can be {majority:.1%} accurate on this dataset while never once')
print('catching a single spam message.')
print('Accuracy is the wrong dial to watch. Through this notebook we report:')
print(' ROC AUC - how well the model ranks spam above ham overall')
print(' F1 (spam) - the balance of precision and recall on the class we care about')
print(' recall - the share of actual spam we caught')
Always-predict-ham accuracy: 0.8660 A model can be 86.6% accurate on this dataset while never once catching a single spam message. Accuracy is the wrong dial to watch. Through this notebook we report: ROC AUC - how well the model ranks spam above ham overall F1 (spam) - the balance of precision and recall on the class we care about recall - the share of actual spam we caught
A model can be 86.6% accurate without catching a single spam message, just by always predicting "ham." So accuracy is the wrong number to watch here. This notebook reports ROC AUC (how well the model ranks spam above ham), F1 on the spam class (the balance of precision and recall on the class that matters) and recall (the share of real spam caught).
Section 1Duplicates come first, before any modeling#
Notebook 2 made this point for tables. It matters even more for text, because real messages repeat all the time
Notebook 2 showed that duplicate rows inflate scores, because the same row can land in both a training fold and the test fold. Text has this problem worst of all, because real message logs are full of repeats: spam templates sent to thousands of numbers, and people who really do send "Ok..." or "Sorry, I'll call later" again and again.
n_dup_rows = sms.duplicated(subset='message').sum()
repeated = sms['message'].value_counts()
repeated = repeated[repeated > 1]
print(f'rows : {len(sms)}')
print(f'duplicate message rows : {n_dup_rows}')
print(f'distinct messages that repeat : {len(repeated)}')
print()
flagged = sms.assign(dup=sms.duplicated(subset='message', keep=False))
print('duplicate rate by class:')
print(flagged.groupby('label')['dup'].mean().round(4))
print()
print('duplicated rows by class (absolute):')
print(flagged[flagged['dup']].groupby('label').size())
print()
print('most repeated messages:')
for m, c in repeated.head(5).items():
print(f' x{c:<3} {m[:72]}')
rows : 5574 duplicate message rows : 403 distinct messages that repeat : 281 duplicate rate by class: label ham 0.1042 spam 0.2423 Name: dup, dtype: float64 duplicated rows by class (absolute): label ham 503 spam 181 dtype: int64 most repeated messages: x30 Sorry, I'll call later x12 I cant pick the phone right now. Pls send a message x10 Ok... x4 Please call our customer service representative on FREEPHONE 0808 145 47 x4 Wen ur lovable bcums angry wid u, dnt take it seriously.. Coz being angr
The duplicate rate is 24.2% for spam and 10.4% for ham, for different reasons. Spam repeats because it's copied from a template to thousands of people. Ham repeats because people reuse the same short phrases. So relative to its size, the class we're trying to detect has the most repeats, which is when leaving them in flatters a model most.
def quick_auc(data, vec=None):
# One TF-IDF + logistic regression pass, scored by ROC AUC.
vec = vec if vec is not None else TfidfVectorizer()
pipe = Pipeline([('vec', vec), ('clf', LogisticRegression(max_iter=2000))])
return cross_val_score(pipe, data['message'].values, data['is_spam'].values,
cv=CV5, scoring='roc_auc', n_jobs=1).mean()
sms_dedup = sms.drop_duplicates(subset='message').reset_index(drop=True)
auc_with = quick_auc(sms)
auc_without = quick_auc(sms_dedup)
print(f'with duplicates n={len(sms):5d} AUC {auc_with:.4f}')
print(f'deduplicated n={len(sms_dedup):5d} AUC {auc_without:.4f}')
print(f'inflation {auc_with - auc_without:+.4f}')
with duplicates n= 5574 AUC 0.9911 deduplicated n= 5171 AUC 0.9889 inflation +0.0023
The inflation here is real but small: +0.0023. The model is already close to 0.99 AUC, so there's little room to inflate, and most duplicated rows are ham, which the model gets right anyway. On a weaker model, or data where the repeats are the hard cases, the same problem would cost much more. Removing duplicates takes one line of code, so there's no reason to take the risk. Every section from here uses the deduplicated data.
One limit: this only removes exact copies. A spam template sent with a different name or phone number in each copy isn't caught, so some near-duplicates remain.
sms = sms_dedup
X_text = sms['message'].values
y = sms['is_spam'].values
print(f'working set: {sms.shape}, spam rate {y.mean():.4f}')
working set: (5171, 3), spam rate 0.1263
Section 2The vocabulary idea, and what it throws away#
Bag of words is the classic starting point. Building one by hand once makes it easy to understand
The classic way to turn text into numbers: collect every distinct word from all documents into a vocabulary. Give each document a row and each word a column. Fill each cell with how many times that word appears in that document. The result is called a document-term matrix.
toy = [
"The pizza was great and the service was great",
"The pizza was cold and the service was slow",
"Great service, great food, will come back",
"Cold food, slow service, never again",
]
toy_cv = CountVectorizer()
toy_dtm = toy_cv.fit_transform(toy)
n_cells_toy = toy_dtm.shape[0] * toy_dtm.shape[1]
print('vocabulary:', list(toy_cv.get_feature_names_out()))
print(f'\nmatrix shape: {toy_dtm.shape} ({toy_dtm.shape[0]} documents x {toy_dtm.shape[1]} words)')
print(f'cells: {n_cells_toy}, non-zero: {toy_dtm.nnz}, zero: {n_cells_toy - toy_dtm.nnz}')
pd.DataFrame(toy_dtm.toarray(),
columns=toy_cv.get_feature_names_out(),
index=[f'doc{i+1}' for i in range(len(toy))])
vocabulary: ['again', 'and', 'back', 'cold', 'come', 'food', 'great', 'never', 'pizza', 'service', 'slow', 'the', 'was', 'will'] matrix shape: (4, 14) (4 documents x 14 words) cells: 56, non-zero: 25, zero: 31
again and back cold come food great never pizza service slow the was will doc1 0 1 0 0 0 0 2 0 1 1 0 2 2 0 doc2 0 1 0 1 0 0 0 0 1 1 1 2 2 0 doc3 0 0 1 0 1 1 2 0 0 1 0 0 0 1 doc4 1 0 0 1 0 1 0 1 0 1 1 0 0 0
A few things to notice in that table. great scores 2 in doc1 because it appears twice. These are counts, not yes/no flags. Punctuation and capital letters disappear, because CountVectorizer lowercases and splits on word characters by default, so "Great" and "great," both become great. The default also ignores words of a single character. That matters for SMS, where u, r and 2 are real words, and it's one reason section 10 looks at characters instead of words.
And even in this small table, 31 of the 56 cells are zero, which previews section 3.
But first, here's what bag of words throws away.
pair = [
'the food was good but the service was not',
'the food was not good but the service was',
]
pair_cv = CountVectorizer().fit(pair)
P = pair_cv.transform(pair).toarray()
print('vocabulary:', list(pair_cv.get_feature_names_out()))
print('sentence 1:', P[0])
print('sentence 2:', P[1])
print()
print('Vectors identical?', bool((P[0] == P[1]).all()))
vocabulary: ['but', 'food', 'good', 'not', 'service', 'the', 'was'] sentence 1: [1 1 1 1 1 2 2] sentence 2: [1 1 1 1 1 2 2] Vectors identical? True
Those two sentences mean nearly opposite things, but bag of words gives them identical vectors. They have the same words the same number of times. Only the order is different, and counting throws the order away.
The usual fix is n-grams: treat runs of neighboring words as tokens too. A bigram model gets a column for not good as well as separate columns for not and good.
pair_bi = CountVectorizer(ngram_range=(1, 2)).fit(pair)
B = pair_bi.transform(pair).toarray()
print('Identical with bigrams?', bool((B[0] == B[1]).all()))
differing = [name for name, a, b in zip(pair_bi.get_feature_names_out(), B[0], B[1]) if a != b]
print('features that now separate them:', differing)
Identical with bigrams? False features that now separate them: ['not good', 'was good']
ngram_range=(1, 2) fixes this case, and the features doing the work are the phrases not good and was good. But be clear about what this does. Bigrams don't teach a model grammar. They give it a fixed set of short phrases that carry meaning the single words lost. That's useful but limited. It also has a cost, which section 4 measures in vocabulary size.
Section 3Sparsity is the whole engineering problem#
On four toy documents, a matrix full of zeros is a curiosity. On real data it decides whether your code runs at all
count_vec = CountVectorizer()
dtm = count_vec.fit_transform(X_text)
n_docs, n_terms = dtm.shape
n_cells = n_docs * n_terms
density = dtm.nnz / n_cells
print(f'documents : {n_docs:,}')
print(f'vocabulary : {n_terms:,}')
print(f'cells in matrix : {n_cells:,}')
print(f'non-zero cells : {dtm.nnz:,}')
print(f'density : {density:.5%}')
print(f'average words stored per message: {dtm.nnz / n_docs:.1f}')
print()
dense_mb = n_cells * 8 / 1e6
sparse_mb = (dtm.data.nbytes + dtm.indices.nbytes + dtm.indptr.nbytes) / 1e6
print(f'as a dense float64 array : {dense_mb:,.1f} MB')
print(f'as the sparse matrix : {sparse_mb:,.2f} MB')
print(f'ratio : {dense_mb / sparse_mb:,.0f}x')
documents : 5,171 vocabulary : 8,713 cells in matrix : 45,054,923 non-zero cells : 68,240 density : 0.15146% average words stored per message: 13.2 as a dense float64 array : 360.4 MB as the sparse matrix : 0.84 MB ratio : 429x
99.85% of that matrix is zero. Storing it in full would take 360 MB to hold 68,240 useful numbers. The sparse format scikit-learn returns by default takes 0.84 MB, about 430 times smaller. That's why every scikit-learn vectorizer returns a scipy.sparse matrix instead of a normal numpy array.
It also means that calling .toarray() on a real document-term matrix is how people run out of memory. It was fine on the four-by-fourteen toy example. It's not fine at 5,171 by 8,713, and it gets much worse with millions of documents.
Why is it so empty? Because some words are used far more than others.
# Two different counts, and mixing them up is a genuine trap.
# term_freq : how many times a word occurs in total
# doc_freq : how many documents contain it at least once
# min_df and max_df both work on DOCUMENT frequency, not total occurrences.
term_freq = np.asarray(dtm.sum(axis=0)).ravel()
doc_freq = np.asarray((dtm > 0).sum(axis=0)).ravel()
order = np.argsort(term_freq)[::-1]
terms_sorted = count_vec.get_feature_names_out()[order]
freq_sorted = term_freq[order]
once = int((term_freq == 1).sum())
print(f'vocabulary size : {n_terms:,}')
print(f'terms occurring exactly once: {once:,} ({once / n_terms:.1%})')
print()
print(f'top 10 terms cover {freq_sorted[:10].sum() / freq_sorted.sum():.1%} of all word occurrences')
print(f'top 100 terms cover {freq_sorted[:100].sum() / freq_sorted.sum():.1%} of all word occurrences')
print()
print('most frequent:', ', '.join(f'{t}({f})' for t, f in zip(terms_sorted[:10], freq_sorted[:10])))
print()
print('the two counts disagree, which is why the distinction matters:')
for cutoff in [2, 3, 5]:
print(f' min_df={cutoff}: keeps {int((doc_freq >= cutoff).sum()):,} terms by document frequency, '
f'but {int((term_freq >= cutoff).sum()):,} if you wrongly used total occurrences')
vocabulary size : 8,713 terms occurring exactly once: 4,799 (55.1%) top 10 terms cover 14.7% of all word occurrences top 100 terms cover 45.5% of all word occurrences most frequent: you(2148), to(2080), the(1245), and(930), in(831), is(800), me(758), it(693), my(691), for(666) the two counts disagree, which is why the distinction matters: min_df=2: keeps 3,802 terms by document frequency, but 3,914 if you wrongly used total occurrences min_df=3: keeps 2,547 terms by document frequency, but 2,610 if you wrongly used total occurrences min_df=5: keeps 1,666 terms by document frequency, but 1,703 if you wrongly used total occurrences
More than half the vocabulary, 55.1%, appears only once in the whole dataset. A word used once can't teach a model anything general. It can only help the model memorize that one document, which is overfitting. Meanwhile, the top 100 words make up 45.5% of all the words in the dataset.
That imbalance is why a vectorizer's two most useful settings exist. min_df drops words that appear in fewer than N documents, removing the one-offs. max_df drops words that appear in more than a set share of documents, removing words too common to tell anything apart.
sizes = [250, 500, 1000, 2000, 3000, 4000, len(X_text)]
vocab_growth = [len(CountVectorizer().fit(X_text[:n]).vocabulary_) for n in sizes]
fig, axes = plt.subplots(1, 3, figsize=(16, 4.4))
ax = axes[0]
ax.spy(dtm[:120, :400], markersize=0.7, aspect='auto', color='#2b6cb0')
ax.set_title('Document-term matrix, top-left corner\n(120 messages x 400 terms)', fontsize=10)
ax.set_xlabel('vocabulary term'); ax.set_ylabel('message')
ax = axes[1]
ax.loglog(np.arange(1, len(freq_sorted) + 1), freq_sorted, color='#2b6cb0', lw=1.6)
for cutoff, colour in [(2, '#dd6b20'), (3, '#c53030')]:
# doc_freq, not term_freq: min_df counts documents, not occurrences.
n_kept = int((doc_freq >= cutoff).sum())
ax.axvline(n_kept, color=colour, ls='--', lw=1.3,
label=f'min_df={cutoff} keeps {n_kept:,}')
ax.set_title('Word frequency is deeply unequal\n(log-log)', fontsize=10)
ax.set_xlabel('term rank'); ax.set_ylabel('total occurrences')
ax.legend(fontsize=8)
ax = axes[2]
ax.plot(sizes, vocab_growth, 'o-', color='#2b6cb0')
ax.set_title('Vocabulary keeps growing with corpus size\n(it never settles)', fontsize=10)
ax.set_xlabel('documents used'); ax.set_ylabel('distinct terms')
plt.tight_layout()
plt.show()
print('vocabulary growth:')
for n, v in zip(sizes, vocab_growth):
print(f' {n:5,} documents -> {v:6,} terms')
The right chart is the one people underestimate. The vocabulary doesn't settle as more data comes in. Every new batch brings new names, typos and short codes, so anything with a fixed number of columns needs a plan for words it has never seen. Section 7 covers that.
The middle chart shows what min_df really costs. Requiring a word to appear in at least 3 documents removes most of the vocabulary but only a small share of the words actually written, because the words cut are mostly the ones used once or twice.
Section 4Bag of words on real data, and what the knobs are worth#
Every score below is 5-fold cross-validated ROC AUC on the deduplicated SMS data with the same logistic regression. Only the text representation changes
def score_vec(vec, metric='roc_auc'):
# Vectoriser and classifier live in one Pipeline so the vocabulary is
# rebuilt from the training rows of every fold. Section 6 explains why.
pipe = Pipeline([('vec', vec), ('clf', LogisticRegression(max_iter=3000))])
return cross_val_score(pipe, X_text, y, cv=CV5, scoring=metric, n_jobs=1).mean()
print('min_df / max_df sweep (TF-IDF):')
print(f"{'setting':30}{'vocabulary':>12}{'ROC AUC':>10}")
for kwargs in [dict(), dict(min_df=2), dict(min_df=3), dict(min_df=5),
dict(min_df=2, max_df=0.5)]:
vocab = len(TfidfVectorizer(**kwargs).fit(X_text).vocabulary_)
label = ', '.join(f'{k}={v}' for k, v in kwargs.items()) or 'defaults'
print(f'{label:30}{vocab:12,}{score_vec(TfidfVectorizer(**kwargs)):10.4f}')
min_df / max_df sweep (TF-IDF): setting vocabulary ROC AUC
defaults 8,713 0.9889
min_df=2 3,802 0.9894 min_df=3 2,547 0.9896
min_df=5 1,666 0.9893 min_df=2, max_df=0.5 3,802 0.9894
This is a good result. Going from the defaults to min_df=3, the vocabulary drops from 8,713 to 2,547 words, a 71% cut, and AUC doesn't drop. It even rises by 0.0007, which is too small to call a real gain. We removed almost three quarters of the columns and lost nothing, because those columns carried almost no signal. A smaller matrix also trains faster and takes less space. max_df=0.5 changes nothing here, because no word appears in more than half of all messages.
Now let's measure the n-gram question from section 2.
print('n-gram range (TF-IDF, min_df=2):')
print(f"{'range':12}{'vocabulary':>12}{'ROC AUC':>10}{'F1 (spam)':>12}")
for ng in [(1, 1), (1, 2), (1, 3)]:
v = TfidfVectorizer(ngram_range=ng, min_df=2)
vocab = len(v.fit(X_text).vocabulary_)
print(f'{str(ng):12}{vocab:12,}'
f'{score_vec(TfidfVectorizer(ngram_range=ng, min_df=2)):10.4f}'
f'{score_vec(TfidfVectorizer(ngram_range=ng, min_df=2), "f1"):12.4f}')
n-gram range (TF-IDF, min_df=2): range vocabulary ROC AUC F1 (spam)
(1, 1) 3,802 0.9894 0.8566
(1, 2) 12,369 0.9901 0.8423
(1, 3) 17,216 0.9895 0.8328
Here, common advice and actual measurement disagree. Bigrams raise AUC by a tiny +0.0007, use 3.3 times as many columns and actually lower F1 on spam. (F1 is measured at the default 0.5 cutoff, so part of that drop may come from where the cutoff sits rather than from worse features. Section 9 comes back to this.) Trigrams are worse than bigrams on both. Bigrams fixed the not good problem in section 2, but on this task they're close to useless.
That makes sense. Negation matters a lot for sentiment and barely at all for spam, because spam isn't making subtle points about a product. It wants you to call a number. The same lesson from earlier notebooks applies: a technique isn't good or bad in general, only for a specific task, and you find out by testing it.
Section 5TF-IDF, and how much the weighting actually buys#
Raw counts have an obvious flaw: "the" appears more than almost any useful word in nearly every document, adding volume but not meaning
TF-IDF adjusts each count based on how rare the word is across all documents. TF, term frequency, is how often the word appears in this document. IDF, inverse document frequency, is how rare it is across all documents. The value stored is the two multiplied together, and each row is then scaled to length 1.
Textbooks usually write IDF as log(N / df), which drops to zero for a word that appears in every document. Scikit-learn doesn't use that by default. With its default smooth_idf=True, it calculates
idf = ln((1 + N) / (1 + df)) + 1
The +1 at the end matters: a word in every document gets a weight of exactly 1.0, never 0, so no word is multiplied away to nothing. The smoothing inside the log acts as if there were one extra document containing every word, so the math never divides by zero.
toy_tfidf = TfidfVectorizer()
toy_tfidf.fit(toy)
idf_table = (pd.DataFrame({'term': toy_tfidf.get_feature_names_out(),
'idf': toy_tfidf.idf_.round(3)})
.sort_values('idf')
.reset_index(drop=True))
print('IDF on the four toy reviews:')
print(idf_table.to_string(index=False))
print()
print("'service' appears in all 4 documents and sits exactly on the floor of 1.0.")
print("'never' and 'again' appear in 1 document each, so they get the highest weight.")
print()
n_toy, df_service = len(toy), 4
print('checking the formula by hand for "service":')
print(f' ln((1 + {n_toy}) / (1 + {df_service})) + 1 = {np.log((1 + n_toy) / (1 + df_service)) + 1:.4f}')
print(f' the textbook log(N / df) would give = {np.log(n_toy / df_service):.4f}')
IDF on the four toy reviews:
term idf
service 1.000
and 1.511
cold 1.511
food 1.511
great 1.511
pizza 1.511
slow 1.511
the 1.511
was 1.511
again 1.916
back 1.916
come 1.916
never 1.916
will 1.916
'service' appears in all 4 documents and sits exactly on the floor of 1.0.
'never' and 'again' appear in 1 document each, so they get the highest weight.
checking the formula by hand for "service":
ln((1 + 4) / (1 + 4)) + 1 = 1.0000
the textbook log(N / df) would give = 0.0000service appears in all four toy reviews and lands exactly at the minimum of 1.0, which matches the formula when checked by hand. never and again each appear in only one review and get the highest weights.
print('Real corpus, TF-IDF with min_df=2:')
tfidf_full = TfidfVectorizer(min_df=2).fit(X_text)
names = np.array(tfidf_full.get_feature_names_out())
idf = tfidf_full.idf_
o = np.argsort(idf)
print('\n lowest IDF (common, pushed down):')
print(' ', ', '.join(f'{names[i]} ({idf[i]:.2f})' for i in o[:10]))
print('\n highest IDF (rare, pushed up):')
print(' ', ', '.join(f'{names[i]} ({idf[i]:.2f})' for i in o[-6:]))
Real corpus, TF-IDF with min_df=2:
lowest IDF (common, pushed down):
to (2.20), you (2.23), the (2.68), and (2.93), in (2.93), is (3.02), me (3.07), for (3.18), it (3.19), my (3.20)
highest IDF (rare, pushed up):
promises (8.45), decisions (8.45), program (8.45), definite (8.45), prove (8.45), zoe (8.45)auc_count = score_vec(CountVectorizer())
auc_tfidf = score_vec(TfidfVectorizer())
print(f'CountVectorizer AUC {auc_count:.4f}')
print(f'TfidfVectorizer AUC {auc_tfidf:.4f}')
print(f'difference {auc_tfidf - auc_count:+.4f}')
CountVectorizer AUC 0.9870 TfidfVectorizer AUC 0.9889 difference +0.0019
TF-IDF wins by +0.0019, which is much smaller than its reputation. Part of that gain isn't even the IDF weighting: TF-IDF also scales every message to the same length, while raw counts give long messages bigger numbers. And there's a reason the rest is small. Logistic regression learns its own coefficient for each word anyway, so it can already learn that "the" is unhelpful and give it a small weight. TF-IDF just gives the model that information upfront.
That helps most when the model is weak, the data is small, or the algorithm can't adjust feature weights itself, like a distance-based method where one very common word would swamp every comparison. TF-IDF is a good default. It won't rescue a struggling project.
Since we have a fitted model, let's look at what it learned. Linear models on text are easy to inspect.
# Fitted on the whole corpus deliberately. We are reading the coefficients to
# understand the data, not estimating performance, so there is no held-out claim
# to protect here. Section 8 covers when this would not be acceptable.
M = tfidf_full.transform(X_text)
inspect_model = LogisticRegression(max_iter=3000).fit(M, y)
coef = inspect_model.coef_[0]
rank = np.argsort(coef)
print('most spam-ish terms:')
print(' ', ', '.join(f'{names[i]} ({coef[i]:+.2f})' for i in rank[::-1][:12]))
print('\nmost ham-ish terms:')
print(' ', ', '.join(f'{names[i]} ({coef[i]:+.2f})' for i in rank[:12]))
most spam-ish terms: txt (+4.51), call (+4.44), text (+3.53), free (+3.36), stop (+3.22), mobile (+3.12), www (+3.00), 150p (+2.95), claim (+2.94), uk (+2.92), reply (+2.92), to (+2.78) most ham-ish terms: me (-2.63), my (-2.27), that (-1.70), ok (-1.63), lt (-1.58), gt (-1.58), can (-1.55), it (-1.47), but (-1.36), come (-1.35), how (-1.34), am (-1.29)
That list sums up the spam filter's view of the world: txt, call, free, mobile, claim, www. The ham side is just as clear: me, my, ok, come, the words of people talking to people they know.
Also notice lt and gt. They come from <#>, which most likely replaced numbers in the ham messages when the dataset was put together. The ham and spam messages were collected from different sources. So the model is partly learning which source a message came from, not what spam looks like. It works on this file, but real messages won't contain that placeholder, and the pattern quietly disappears.
Section 6Cleaning text, and the mistake almost everyone makes#
The standard advice is four steps: lowercase, remove punctuation, remove stopwords, done. Three of those steps are fine
print(f'sklearn English stopword list: {len(ENGLISH_STOP_WORDS)} words')
print()
check = ['not', 'no', 'never', 'nor', 'none', 'cannot', 'without', 'against', 'but', 'very']
for w in check:
print(f' {w!r:10} in the stopword list: {w in ENGLISH_STOP_WORDS}')
sklearn English stopword list: 318 words 'not' in the stopword list: True 'no' in the stopword list: True 'never' in the stopword list: True 'nor' in the stopword list: True 'none' in the stopword list: True 'cannot' in the stopword list: True 'without' in the stopword list: True 'against' in the stopword list: True 'but' in the stopword list: True 'very' in the stopword list: True
All of those words are on the list, including not, no, never and without. Scikit-learn's own documentation warns that this list has known problems. There's a reason for it: the list was made for search engines, where small function words really are noise between a search and a match. But copy that list into a sentiment or intent model and you've deleted negation from what the model can see. Here's what that does.
def clean_text(text, stopwords=ENGLISH_STOP_WORDS):
# The textbook recipe: lowercase, drop punctuation and digits, drop stopwords.
text = text.lower()
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\d+', ' ', text)
tokens = [t for t in text.split() if t not in stopwords]
return ' '.join(tokens)
pairs = [
("The movie was not good at all.", "The movie was good."),
("I would never buy this again.", "I would buy this again."),
("This is not worth the money.", "This is worth the money."),
]
for negative, positive in pairs:
cn, cp = clean_text(negative), clean_text(positive)
print(f' {negative!r}')
print(f' -> {cn!r}')
print(f' {positive!r}')
print(f' -> {cp!r}')
print(f' identical after cleaning: {cn == cp}')
print()
'The movie was not good at all.'
-> 'movie good'
'The movie was good.'
-> 'movie good'
identical after cleaning: True
'I would never buy this again.'
-> 'buy'
'I would buy this again.'
-> 'buy'
identical after cleaning: True
'This is not worth the money.'
-> 'worth money'
'This is worth the money.'
-> 'worth money'
identical after cleaning: TrueThree pairs of sentences with opposite meanings, and after "standard" cleaning, each pair becomes the exact same string. No vectorizer, model or tuning can recover a difference that was destroyed before the text arrived.
SMS spam is the wrong dataset to measure this, since spam detection doesn't depend on negation. So for this question, the notebook uses the second dataset: 3,000 review sentences from Amazon, IMDB and Yelp, split evenly between positive and negative.
rev = pd.read_csv('datasets/review_sentiment.csv')
print('shape:', rev.shape)
print(rev['sentiment'].value_counts().to_dict())
print(rev['source'].value_counts().to_dict())
print()
X_rev, y_rev = rev['text'].values, rev['sentiment'].values
NEGATION = {'not', 'no', 'never', 'nor', 'nothing', 'none', 'cannot', 'without'}
has_negation = rev['text'].str.lower().str.findall(r"[a-z']+").apply(
lambda ws: bool(NEGATION & set(ws)))
print(f'sentences containing a negation stopword: {has_negation.sum()} / {len(rev)} '
f'({has_negation.mean():.1%})')
print(f'positive rate among those sentences : {y_rev[has_negation.values].mean():.3f}')
print(f'positive rate overall : {y_rev.mean():.3f}')
shape: (3000, 3)
{0: 1500, 1: 1500}
{'amazon': 1000, 'imdb': 1000, 'yelp': 1000}
sentences containing a negation stopword: 460 / 3000 (15.3%)
positive rate among those sentences : 0.217
positive rate overall : 0.50015.3% of the sentences contain one of those words, and in those sentences only 0.217 are positive, against 0.500 overall. So these words aren't noise here. They're one of the strongest signals in the dataset, and the standard recipe throws them all away. The next comparison uses thirty folds instead of five, because the differences are small enough that normal fold-to-fold variation could hide them.
KEEP_THESE = {'not', 'no', 'never', 'nor', 'nothing', 'none', 'cannot',
'without', 'against', 'but', 'very', 'too'}
CURATED = ENGLISH_STOP_WORDS - KEEP_THESE
rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=6, random_state=42)
def score_sentiment(**vec_kwargs):
pipe = Pipeline([('vec', TfidfVectorizer(**vec_kwargs)),
('clf', LogisticRegression(max_iter=2000))])
s = cross_val_score(pipe, X_rev, y_rev, cv=rcv, scoring='accuracy', n_jobs=1)
return s.mean(), s.std()
print(f'full stopword list: {len(ENGLISH_STOP_WORDS)} words')
print(f'curated list : {len(CURATED)} words ({len(KEEP_THESE)} put back)')
print()
print('sentiment accuracy over 30 folds:')
print(f"{'strategy':40}{'accuracy':>10}{'sd':>9}")
for label, kwargs in [
('keep every word', dict()),
('remove sklearn stopwords', dict(stop_words='english')),
('remove stopwords, keep negation', dict(stop_words=list(CURATED))),
]:
m, sd = score_sentiment(**kwargs)
print(f'{label:40}{m:10.4f}{sd:9.4f}')
full stopword list: 318 words curated list : 306 words (12 put back) sentiment accuracy over 30 folds: strategy accuracy sd
keep every word 0.8179 0.0173
remove sklearn stopwords 0.8112 0.0159
remove stopwords, keep negation 0.8391 0.0126
The result is clear, and it isn't the either-or choice people usually assume. Removing stopwords the standard way costs 0.0067 accuracy compared with doing nothing. Removing stopwords but keeping twelve specific words (the negations, plus but, very and too) beats both, at 0.8391, a gain of 0.0279 over the standard recipe.
So the best answer was neither "remove stopwords" nor "keep everything." It was: remove stopwords, but adjust the list for your task. That takes about a minute and is almost never done. If you can't justify a cleaning step for your task, don't use it just because a tutorial did. Since stop_words accepts a plain list, a custom list is one line of code.
For completeness, here's the same comparison on spam, where we expected no difference at all.
print('SMS spam, same comparison (5-fold AUC):')
print(f' keep every word {score_vec(TfidfVectorizer()):.4f}')
print(f' remove sklearn stopwords {score_vec(TfidfVectorizer(stop_words="english")):.4f}')
SMS spam, same comparison (5-fold AUC): keep every word 0.9889
remove sklearn stopwords 0.9890
The same to three decimal places, a difference of +0.0001. As expected, since nobody writes "this is not a free prize." Same step, same code: on one task it costs real accuracy, on the other it costs nothing.
Section 7The leakage rule, in its text-shaped form#
The rule from earlier notebooks again: anything with a fit method learns from data, and everything it learns must come from training rows only
CountVectorizer and TfidfVectorizer both have a fit method, and both learn two things from the data: the vocabulary (which words get a column) and the IDF weights (based on how many documents each word appears in). Running vectorizer.fit_transform(all_text) and only then splitting into train and test is leakage. The test documents helped decide which words become columns and how much each one counts. It's one of the most common leaks in real text projects, because writing it that way feels natural.
Xtr, Xte, ytr, yte = train_test_split(X_text, y, test_size=0.25,
stratify=y, random_state=42)
# The wrong way: vocabulary built from every document, test included.
vec_leaky = TfidfVectorizer(min_df=2).fit(X_text)
model_leaky = LogisticRegression(max_iter=2000).fit(vec_leaky.transform(Xtr), ytr)
auc_leaky = roc_auc_score(yte, model_leaky.predict_proba(vec_leaky.transform(Xte))[:, 1])
# The right way: vocabulary built from training documents only.
vec_clean = TfidfVectorizer(min_df=2).fit(Xtr)
model_clean = LogisticRegression(max_iter=2000).fit(vec_clean.transform(Xtr), ytr)
auc_clean = roc_auc_score(yte, model_clean.predict_proba(vec_clean.transform(Xte))[:, 1])
only_from_test = set(vec_leaky.vocabulary_) - set(vec_clean.vocabulary_)
print(f'vocabulary fit on ALL data : {len(vec_leaky.vocabulary_):5,} terms test AUC {auc_leaky:.4f}')
print(f'vocabulary fit on TRAIN : {len(vec_clean.vocabulary_):5,} terms test AUC {auc_clean:.4f}')
print(f'difference {auc_leaky - auc_clean:+.4f}')
print()
print(f'terms in the model only because test data was included: {len(only_from_test):,}')
print(' examples:', ', '.join(sorted(only_from_test)[:8]))
vocabulary fit on ALL data : 3,802 terms test AUC 0.9889 vocabulary fit on TRAIN : 3,163 terms test AUC 0.9885 difference +0.0004 terms in the model only because test data was included: 639 examples: 008704050406, 02073162414, 021, 07123456789, 08, 08000407165, 0844, 0845
Read this carefully, because the headline number is the least interesting part. The AUC difference is just +0.0004, which looks like nothing. Judging by that alone, you'd decide it doesn't matter. But look at the second number: 639 words are in the leaky model's vocabulary only because test documents helped build it. The score barely moves because those words are rare.
Here's how this leak actually works. A word that appears only in test messages gets no weight at all, because the model never sees it during training. The leak comes from words seen once in training and once in test. min_df=2 would normally drop them, but the test copy lets them in. The model learns a weight from the training copy, then uses it on exactly the test message that let the word in. The IDF weights shift too.
Before drawing a general conclusion, check how much the setup affected the result. The share of leaked vocabulary depends on how much data was held out.
print('how much of the vocabulary comes from test documents, by split size:')
print(f"{'test_size':>10}{'train rows':>12}{'train vocab':>13}{'from test':>11}{'share':>9}")
for test_size in [0.25, 0.50, 0.75, 0.90]:
Xa, _, _, _ = train_test_split(X_text, y, test_size=test_size,
stratify=y, random_state=42)
v_all = TfidfVectorizer(min_df=2).fit(X_text)
v_tr = TfidfVectorizer(min_df=2).fit(Xa)
from_test = len(set(v_all.vocabulary_) - set(v_tr.vocabulary_))
print(f'{test_size:10.2f}{len(Xa):12,}{len(v_tr.vocabulary_):13,}'
f'{from_test:11,}{from_test / len(v_all.vocabulary_):9.1%}')
how much of the vocabulary comes from test documents, by split size:
test_size train rows train vocab from test share
0.25 3,878 3,163 639 16.8%
0.50 2,585 2,401 1,401 36.8% 0.75 1,292 1,504 2,298 60.4%
0.90 517 786 3,016 79.3%With a 25% test split, 17% of the vocabulary came from data the model shouldn't have seen. With a 90% split, it's 79%. That's why the rule has to be followed every time, not decided case by case. The size of the leak depends on the setup, so a small leak in one experiment tells you little about the next one.
So this notebook's +0.0004 doesn't mean "vectorizer leakage doesn't matter." It means "it didn't matter much at this split size, on this data, with this model." Notebook 4's target-encoding leak, which touched the labels directly, was worth a full 0.0200 in the same kind of comparison.
# Everything before this point in the notebook already used a Pipeline for exactly
# this reason: cross_val_score refits every step on each fold's training rows.
pipe = Pipeline([
('vec', TfidfVectorizer(min_df=2)),
('clf', LogisticRegression(max_iter=3000)),
])
print('Inside cross_val_score, this pipeline rebuilds the vocabulary 5 times,')
print('once per fold, each time from that fold\'s training documents only.')
print()
print(f'AUC {cross_val_score(pipe, X_text, y, cv=CV5, scoring="roc_auc", n_jobs=1).mean():.4f}')
Inside cross_val_score, this pipeline rebuilds the vocabulary 5 times, once per fold, each time from that fold's training documents only.
AUC 0.9894
Fitting on training data only has another practical effect: the test set will always contain words the model has never seen. Both vectorizers simply ignore unknown words, which is a sensible default. But a message made entirely of unseen words becomes a row of zeros, just like notebook 4's handle_unknown='ignore'. The model can only use its intercept, so every such message gets the same score. Section 10 covers a method that handles this better.
Section 8Hand-crafted features, which turn out to beat the vocabulary#
So far we've only asked which words appear. Look back at section 1's messages: a lot of the signal is in how a message is written
Spam has to include a way to respond: a phone number, a short code, a price or a web address. These leave number patterns that have nothing to do with word choice, and they survive even when a spammer rewrites the message.
class StyleFeatures(BaseEstimator, TransformerMixin):
# Turns raw strings into a dense array of writing-style statistics.
# Stateless: fit does nothing, so there is nothing here that can leak.
names = ['n_chars', 'n_words', 'avg_word_len', 'digit_ratio', 'upper_ratio',
'n_exclam', 'n_currency', 'has_url', 'n_longnum']
def fit(self, X, y=None):
return self
def transform(self, X):
rows = []
for text in X:
words = text.split()
length = max(len(text), 1)
rows.append([
len(text),
len(words),
np.mean([len(w) for w in words]) if words else 0.0,
sum(c.isdigit() for c in text) / length,
sum(c.isupper() for c in text) / length,
text.count('!'),
sum(text.count(c) for c in '£$€'),
float(bool(re.search(r'http|www\.', text, re.I))),
len(re.findall(r'\d{5,}', text)), # phone numbers, short codes
])
return np.asarray(rows, dtype=float)
def get_feature_names_out(self, input_features=None):
return np.array(self.names)
style_matrix = pd.DataFrame(StyleFeatures().transform(X_text),
columns=StyleFeatures.names)
by_class = style_matrix.assign(is_spam=y).groupby('is_spam').mean().T
by_class.columns = ['ham', 'spam']
by_class['ratio'] = (by_class['spam'] / by_class['ham'].replace(0, np.nan)).round(1)
print('mean value by class:')
print(by_class.round(3).to_string())
# A mean of 0.998 does not imply "one per message". Check the actual shape.
print('\nshare of messages containing at least one long digit run:')
for label, mask in [('ham', y == 0), ('spam', y == 1)]:
col = style_matrix.loc[mask, 'n_longnum']
print(f' {label:5} {(col > 0).mean():7.2%} ({int((col > 0).sum()):,} of {int(mask.sum()):,})')
print('\ncount distribution for spam:')
print(style_matrix.loc[y == 1, 'n_longnum'].astype(int).value_counts().sort_index().to_string())
mean value by class:
ham spam ratio
n_chars 70.895 137.711 1.9
n_words 14.233 23.740 1.7
avg_word_len 4.149 4.977 1.2
digit_ratio 0.004 0.115 26.5
upper_ratio 0.057 0.110 1.9
n_exclam 0.178 0.698 3.9
n_currency 0.005 0.415 78.1
has_url 0.000 0.139 629.6
n_longnum 0.001 0.998 1503.7
share of messages containing at least one long digit run:
ham 0.07% (3 of 4,518)
spam 76.72% (501 of 653)
count distribution for spam:
n_longnum
0 152
1 373
2 105
3 23Some of these differences are huge. In an average spam message, 11.5% of the characters are digits, against 0.4% in ham, a 26.5-fold difference. n_longnum, which counts runs of five or more digits in a row (phone numbers, short codes), averages 0.998 for spam and 0.001 for ham. Currency symbols are 78 times more common in spam. Spam is about twice as long as ham on average.
That n_longnum average of 0.998 is easy to misread as "every spam message has exactly one phone number." It doesn't mean that. The full breakdown above shows 76.7% of spam has at least one long number, some have two or three, and 23% have none. The average lands near 1.0 because of that mix, not because every message has exactly one.
This is a general warning for any table of averages: it shows the middle, not the spread. A feature found in three quarters of a class is very different from one found in all of it. On the ham side, the average does tell the full story, since only 3 of 4,518 messages have a long number.
Be a little careful with that ham number, though. If numbers in the ham messages were replaced by a placeholder when the data was collected (section 5's lt and gt), ham looks more number-free here than real personal messages are. The digit features may be stronger on this file than they'd be on a real phone.
corr = (style_matrix.assign(is_spam=y).corr()['is_spam']
.drop('is_spam').sort_values())
fig, axes = plt.subplots(1, 3, figsize=(16, 4.4))
ax = axes[0]
colours = ['#c53030' if v < 0 else '#2b6cb0' for v in corr.values]
ax.barh(corr.index, corr.values, color=colours)
ax.set_title('Correlation of each style feature with is_spam', fontsize=10)
ax.set_xlabel('correlation')
ax = axes[1]
bins = np.linspace(0, 300, 45)
ax.hist(style_matrix.loc[y == 0, 'n_chars'], bins=bins, alpha=0.65,
label='ham', color='#2b6cb0', density=True)
ax.hist(style_matrix.loc[y == 1, 'n_chars'], bins=bins, alpha=0.65,
label='spam', color='#c53030', density=True)
ax.set_title('Message length separates the classes', fontsize=10)
ax.set_xlabel('characters'); ax.set_ylabel('density'); ax.legend()
ax = axes[2]
bins = np.linspace(0, 0.4, 45)
ax.hist(style_matrix.loc[y == 0, 'digit_ratio'], bins=bins, alpha=0.65,
label='ham', color='#2b6cb0', density=True)
ax.hist(style_matrix.loc[y == 1, 'digit_ratio'], bins=bins, alpha=0.65,
label='spam', color='#c53030', density=True)
ax.set_title('Digit ratio separates them even better', fontsize=10)
ax.set_xlabel('fraction of characters that are digits'); ax.legend()
plt.tight_layout()
plt.show()
print('correlation with is_spam:')
print(corr.sort_values(ascending=False).round(3).to_string())
n_longnum alone has a correlation of 0.786 with the label, and digit_ratio reaches 0.758. For comparison, tenure's correlation with churn in the Telco data is about −0.35 (negative, because longer tenure means less churn). Also note that upper_ratio is the weakest of the nine, at 0.159. Writing in capitals is the popular image of spam, but it's the least useful of these signals. Remember that the next time a feature seems obviously right because it matches a stereotype.
So how well do nine simple numbers do against a vocabulary of thousands of words?
style_pipe = Pipeline([('style', StyleFeatures()), ('scale', StandardScaler())])
def evaluate(estimator, label):
row = {'model': label}
for metric in ['roc_auc', 'f1', 'recall', 'precision']:
row[metric] = cross_val_score(estimator, X_text, y, cv=CV5,
scoring=metric, n_jobs=1).mean()
return row
results = [
evaluate(Pipeline([('v', style_pipe), ('c', LogisticRegression(max_iter=3000))]),
'style only (9 columns)'),
evaluate(Pipeline([('v', TfidfVectorizer(min_df=2)), ('c', LogisticRegression(max_iter=3000))]),
'word TF-IDF (3,802 columns)'),
]
print(pd.DataFrame(results).round(4).to_string(index=False))
model roc_auc f1 recall precision
style only (9 columns) 0.9829 0.8880 0.8269 0.9596
word TF-IDF (3,802 columns) 0.9894 0.8566 0.7596 0.9823Nine hand-built columns lose narrowly on AUC, 0.9829 against 0.9894, and clearly win on F1, 0.8880 against 0.8566, thanks to better recall: 0.8269 against 0.7596. The TF-IDF model is more precise, 0.9823 against 0.9596, so when it says spam it's almost always right. But it only catches 76% of real spam, while the nine numbers catch 83%.
That's worth thinking about. A method with about 400 times fewer columns and no vocabulary to learn or store, which is harder to fool by rewording, beats the standard text approach on F1, the metric that matters most here.
There's also no rule that these features need a linear model. Style features are few, dense and full of natural cutoff points, which is exactly what trees are good at.
rf_style = Pipeline([('style', StyleFeatures()),
('clf', RandomForestClassifier(n_estimators=300,
random_state=42, n_jobs=1))])
print('Random forest on the same 9 style features:')
print(f" ROC AUC {cross_val_score(rf_style, X_text, y, cv=CV5, scoring='roc_auc', n_jobs=1).mean():.4f}")
print(f" F1 {cross_val_score(rf_style, X_text, y, cv=CV5, scoring='f1', n_jobs=1).mean():.4f}")
Random forest on the same 9 style features:
ROC AUC 0.9876
F1 0.9373
An F1 of 0.9373 from nine columns and a random forest, better than anything built from words so far. Notice there's no scaler in that pipeline. Trees split on thresholds, so rescaling can't change which side of a split a row falls on, the same point notebook 3 made.
The logistic regression above did need StandardScaler, because n_chars goes into the hundreds while digit_ratio stays between 0 and 1. Without scaling, regularization would penalize the two unevenly, just because of their units.
Section 9Combining sparse and dense features#
TF-IDF and style features describe different things about the same message, so the next step is to use both
The practical problem is that TF-IDF returns a sparse matrix and StyleFeatures returns a dense array. FeatureUnion is made for this. It runs both on the same input and joins the results, keeping the output sparse, all inside one estimator. So cross-validation still refits both parts correctly on every fold.
word_tfidf = TfidfVectorizer(min_df=2)
char_tfidf = TfidfVectorizer(analyzer='char_wb', ngram_range=(2, 4), min_df=2)
def combo(*branches):
return Pipeline([
('features', FeatureUnion(list(branches))),
('clf', LogisticRegression(max_iter=3000)),
])
table = [
evaluate(Pipeline([('v', style_pipe), ('c', LogisticRegression(max_iter=3000))]),
'style only'),
evaluate(Pipeline([('v', word_tfidf), ('c', LogisticRegression(max_iter=3000))]),
'word TF-IDF'),
evaluate(combo(('w', word_tfidf), ('s', style_pipe)),
'word TF-IDF + style'),
]
print(pd.DataFrame(table).round(4).to_string(index=False))
model roc_auc f1 recall precision
style only 0.9829 0.8880 0.8269 0.9596
word TF-IDF 0.9894 0.8566 0.7596 0.9823
word TF-IDF + style 0.9927 0.9065 0.8468 0.9757This is the most important table in the notebook, because its four metrics tell very different stories depending on which one you look at.
| metric | word TF-IDF | + style | change |
|---|---|---|---|
| ROC AUC | 0.9894 | 0.9927 | +0.0033 |
| F1 (spam) | 0.8566 | 0.9065 | +0.0499 |
| recall | 0.7596 | 0.8468 | +0.0872 |
| precision | 0.9823 | 0.9757 | −0.0066 |
If you only watched AUC, you'd report a gain of about +0.003, shrug, and maybe decide the extra code wasn't worth it. If you watched recall, you'd report that the filter now catches almost 9 more spam messages in every 100, for less than one point of precision. Same experiment, same numbers, two different decisions.
One caution to keep this fair. Recall, precision and F1 are all measured at the default cutoff of 0.5. Part of the recall gap could be closed just by lowering the word model's cutoff (see section 11). A stricter comparison fixes precision at the same level for both models, then compares recall.
The metric isn't something to pick at the end. It decides which features look worth building in the first place. When models are already very good, AUC is often the least sensitive measure. Once almost every spam-ham pair is ranked correctly, there's very little room left for it to move. On unbalanced problems, average precision (the area under the precision-recall curve) is a cutoff-free alternative that shows differences more clearly.
Section 10When words are the wrong unit#
Two more approaches, each fixing a weakness we've already hit in this notebook
Character n-grams slide a window over letters instead of words. That sounds odd until you remember what SMS text looks like: txt, 2morrow, FREEEE, w1n. A word vocabulary treats every spelling as a separate, unseen word. A character model sees the letter chunks those spellings still share.
print(f"{'analyzer':10}{'range':10}{'vocabulary':>12}{'ROC AUC':>10}{'F1':>9}")
for analyzer, ng in [('word', (1, 1)), ('word', (1, 2)),
('char_wb', (2, 4)), ('char_wb', (3, 5))]:
v = TfidfVectorizer(analyzer=analyzer, ngram_range=ng, min_df=2)
vocab = len(v.fit(X_text).vocabulary_)
est = Pipeline([('v', TfidfVectorizer(analyzer=analyzer, ngram_range=ng, min_df=2)),
('c', LogisticRegression(max_iter=3000))])
auc = cross_val_score(est, X_text, y, cv=CV5, scoring='roc_auc', n_jobs=1).mean()
f1 = cross_val_score(est, X_text, y, cv=CV5, scoring='f1', n_jobs=1).mean()
print(f'{analyzer:10}{str(ng):10}{vocab:12,}{auc:10.4f}{f1:9.4f}')
analyzer range vocabulary ROC AUC F1
word (1, 1) 3,802 0.9894 0.8566
word (1, 2) 12,369 0.9901 0.8423
char_wb (2, 4) 22,631 0.9942 0.8978
char_wb (3, 5) 35,598 0.9921 0.8663
Character n-grams of length 2 to 4 give the best text-only result in this notebook: AUC 0.9942 and F1 0.8978, beating word unigrams and bigrams on both. The cost is 22,631 columns instead of 3,802. Using char_wb instead of plain char keeps each window inside a single word, so it doesn't create meaningless chunks that cross two unrelated words.
Hashing solves a different problem. Every vectorizer so far stores a Python dictionary that maps words to columns, and section 3 showed that dictionary keeps growing. HashingVectorizer replaces the dictionary with a hash function: a word's column is just hash(term) % n_features. There's no dictionary, no fit step and the number of columns never changes, however much text comes in.
# alternate_sign=False keeps every value non-negative, which some downstream
# steps require. The default True lets colliding terms cancel on average, and on
# this corpus the two settings land within 0.002 of each other.
print('HashingVectorizer, no vocabulary stored anywhere:')
print(f"{'n_features':>12}{'collided terms':>17}{'ROC AUC':>10}")
vocab_terms = list(CountVectorizer().fit(X_text).vocabulary_.keys())
for n_features in [2 ** 10, 2 ** 12, 2 ** 14, 2 ** 18]:
est = Pipeline([('v', HashingVectorizer(n_features=n_features,
alternate_sign=False, norm='l2')),
('c', LogisticRegression(max_iter=3000))])
auc = cross_val_score(est, X_text, y, cv=CV5, scoring='roc_auc', n_jobs=1).mean()
# How many of the real vocabulary terms share a column with another term?
buckets = np.array([murmurhash3_bytes_s32(t.encode('utf-8'), 0) % n_features
for t in vocab_terms])
collided = len(vocab_terms) - len(np.unique(buckets))
print(f'{n_features:12,}{collided:11,} ({collided / len(vocab_terms):4.0%}){auc:10.4f}')
print()
# Fresh throwaway vectoriser: fitting the shared `word_tfidf` here would leave it
# fitted on the full corpus, which is the exact habit section 8 argued against.
reference_vocab = len(TfidfVectorizer(min_df=2).fit(X_text).vocabulary_)
print(f'For reference, TF-IDF stores a {reference_vocab:,}-term dictionary and scores 0.9894.')
HashingVectorizer, no vocabulary stored anywhere:
n_features collided terms ROC AUC
1,024 7,689 ( 88%) 0.9823 4,096 5,080 ( 58%) 0.9845
16,384 1,919 ( 22%) 0.9847262,144 122 ( 1%) 0.9847 For reference, TF-IDF stores a 3,802-term dictionary and scores 0.9894.
The collision column is the real surprise. At 1,024 columns, 88% of the vocabulary shares a column with another word, yet the model still scores 0.9823, only about 0.007 below full TF-IDF. Collisions hurt less than you'd think, because most words are rare (section 3). A common word usually shares its column with rare ones, which add very little noise. Hashing levels off around 0.9847, about 0.005 below TF-IDF, and is already at 0.9845 with 4,096 columns.
That last gap isn't caused by collisions. At 262,144 columns only 1% of words collide, and the score is the same. The gap comes from what hashing skips: IDF weighting and min_df, which both need document counts learned in a fit step.
What that half point buys you: no vocabulary to keep in memory or ship with the model, no fit step so it's easy to run in parallel, new words land in some column instead of being dropped, and the width never changes, so new data can't grow the feature space.
What it costs: some collisions, which are cheap here but not free, and interpretability. You can't map a column back to a word, so the coefficient check from section 5 no longer works. It's the same trade as the hashing encoder in notebook 4, and it's mainly a deployment decision rather than a modeling one.
Section 11Putting it together and testing it once#
Everything above was cross-validated on the same data many times, so some choices are now tuned to it. The honest last step is a hold-out set, with one caveat covered at the end
final_table = [
evaluate(Pipeline([('v', style_pipe), ('c', LogisticRegression(max_iter=3000))]), 'style only'),
evaluate(Pipeline([('v', word_tfidf), ('c', LogisticRegression(max_iter=3000))]), 'word TF-IDF'),
evaluate(Pipeline([('v', char_tfidf), ('c', LogisticRegression(max_iter=3000))]), 'char TF-IDF'),
evaluate(combo(('w', word_tfidf), ('s', style_pipe)), 'word + style'),
evaluate(combo(('c', char_tfidf), ('s', style_pipe)), 'char + style'),
evaluate(combo(('w', word_tfidf), ('c', char_tfidf), ('s', style_pipe)), 'word + char + style'),
]
print('5-fold cross-validation, deduplicated SMS:')
print(pd.DataFrame(final_table).round(4).to_string(index=False))
5-fold cross-validation, deduplicated SMS:
model roc_auc f1 recall precision
style only 0.9829 0.8880 0.8269 0.9596
word TF-IDF 0.9894 0.8566 0.7596 0.9823
char TF-IDF 0.9942 0.8978 0.8147 1.0000
word + style 0.9927 0.9065 0.8468 0.9757
char + style 0.9930 0.9064 0.8468 0.9756
word + char + style 0.9940 0.9179 0.8667 0.9761summary = pd.DataFrame(final_table).set_index('model')
fig, axes = plt.subplots(1, 2, figsize=(15, 4.6))
ax = axes[0]
x = np.arange(len(summary))
width = 0.27
for offset, metric, colour in [(-width, 'roc_auc', '#2b6cb0'),
(0.0, 'f1', '#dd6b20'),
(width, 'recall', '#c53030')]:
ax.bar(x + offset, summary[metric], width, label=metric, color=colour)
ax.set_xticks(x)
ax.set_xticklabels(summary.index, rotation=30, ha='right', fontsize=8)
ax.set_ylim(0.70, 1.005)
ax.set_ylabel('score')
ax.set_title('AUC looks flat while F1 and recall move a lot', fontsize=10)
ax.legend(fontsize=8)
ax = axes[1]
base = summary.loc['word TF-IDF']
delta = (summary - base).drop(index='word TF-IDF')
for metric, colour, marker in [('roc_auc', '#2b6cb0', 'o'),
('f1', '#dd6b20', 's'),
('recall', '#c53030', '^')]:
ax.plot(delta.index, delta[metric], marker + '-', color=colour, label=metric)
ax.axhline(0, color='#666666', lw=1)
ax.set_xticks(range(len(delta)))
ax.set_xticklabels(delta.index, rotation=30, ha='right', fontsize=8)
ax.set_ylabel('change vs word TF-IDF')
ax.set_title('Same experiments, measured as a change from the word baseline', fontsize=10)
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
print('spread of each metric across the six models:')
for m in ['roc_auc', 'f1', 'recall']:
print(f' {m:8} min {summary[m].min():.4f} max {summary[m].max():.4f}'
f' spread {summary[m].max() - summary[m].min():.4f}')
The left chart sums up this notebook. The blue bars barely change across six very different feature sets, while the orange and red bars clearly move. The printed spread confirms it: AUC varies by about a hundredth across the six models, while F1 and recall vary five to ten times as much. If AUC is the only number on your dashboard, most of your feature engineering will look like it did nothing.
Two candidates go to the hold-out set instead of picking one from cross-validation alone. word + char + style won on both F1 and recall. char + style is simpler and lost by 0.0115 F1. That gap is small, but this notebook has treated gaps that size as real before, so calling it a tie now would be inconsistent. Both get tested.
One model is missing from this shortlist. Section 8's random forest on the nine style features scored an F1 of 0.9373, higher than both. Its AUC was lower (0.9876), and this final comparison sticks to logistic regression, but on F1 alone it would have earned a place.
print(f'Hold-out set: {len(Xte)} messages, {int(yte.sum())} of them spam')
print()
candidates = {
'char + style': combo(('c', char_tfidf), ('s', style_pipe)),
'word + char + style': combo(('w', word_tfidf), ('c', char_tfidf), ('s', style_pipe)),
}
fitted = {}
rows = []
for name, est in candidates.items():
est.fit(Xtr, ytr)
fitted[name] = est
pred = est.predict(Xte)
proba = est.predict_proba(Xte)[:, 1]
rows.append({
'model': name,
'test AUC': roc_auc_score(yte, proba),
'spam recall': (pred[yte == 1] == 1).mean(),
'spam precision': (yte[pred == 1] == 1).mean(),
'false positives': int(((pred == 1) & (yte == 0)).sum()),
})
print(pd.DataFrame(rows).round(4).to_string(index=False))
Hold-out set: 1293 messages, 163 of them spam
model test AUC spam recall spam precision false positives
char + style 0.9936 0.8712 0.993 1
word + char + style 0.9943 0.8957 1.000 0best_name = 'word + char + style'
pred = fitted[best_name].predict(Xte)
print(f'{best_name} on the hold-out:')
print()
print(classification_report(yte, pred, target_names=['ham', 'spam'], digits=4))
print('confusion matrix:')
print(pd.DataFrame(confusion_matrix(yte, pred),
index=['actual ham', 'actual spam'],
columns=['predicted ham', 'predicted spam']))
word + char + style on the hold-out:
precision recall f1-score support
ham 0.9852 1.0000 0.9925 1130
spam 1.0000 0.8957 0.9450 163
accuracy 0.9869 1293
macro avg 0.9926 0.9479 0.9688 1293
weighted avg 0.9870 0.9869 0.9865 1293
confusion matrix:
predicted ham predicted spam
actual ham 1130 0
actual spam 17 146The cross-validation ranking held. On messages it has never seen, word + char + style reaches an AUC of 0.9943 and catches about 90% of spam. That last number is what a product team cares about most. Wrongly blocking a real message from someone's family is much worse than letting one spam through, so a filter that lets every real message through while catching most spam is a good balance.
If the balance is wrong for your product, you don't need to retrain. Just move the decision threshold on predict_proba to trade recall for precision.
Two honest caveats come with that result. First, zero false positives is a good sign, not a guarantee. A simple rule of thumb, the "rule of three", says that with 0 mistakes in 1,130 real messages, the true rate could still be as high as about 3 in 1,130, roughly 0.3%.
Second, both candidates were chosen by reading the same cross-validation table, so their CV scores were never clean estimates of future performance. The hold-out is better, but not perfectly clean either. Those 1,293 messages were also part of the data every cross-validation above used, so they played a small part in choosing these candidates.
A truly clean test sets the hold-out aside at the very start, before any exploring. With only a handful of options compared here, the effect is probably small, but treat the hold-out numbers as slightly optimistic.
Wrap-upKey things to remember from this notebook#
Text becomes a matrix, and that matrix is almost all zeros. 5,171 SMS messages produced an 8,713-word vocabulary that was 99.85% zeros. Storing it in full would take 360 MB, against 0.84 MB in sparse form. Keep it sparse, and be careful with .toarray() on real data.
Half the vocabulary is noise. 55% of words appeared only once. Setting min_df=3 cut the vocabulary by about 70% and AUC went slightly up. min_df and max_df count documents, not total uses, and mixing them up gives the wrong idea of what survives.
Bag of words throws away word order, and n-grams only partly fix it. Two sentences with opposite meanings got identical single-word vectors. Adding bigrams separated them through one feature, not good. On spam, though, bigrams gave a tiny AUC gain for over three times the columns and lowered F1. Whether a technique helps depends on the task, not the technique.
TF-IDF is a good default with a small effect. It beat raw counts by +0.0019 here. It helps most when a model can't already learn to down-weight common words, like a distance-based method with no way to rescale features.
Don't remove stopwords by habit. Scikit-learn's list includes not, no, never and without. On review sentiment, removing the full list cost 0.0067 accuracy, while removing stopwords but keeping twelve words, mostly negations, scored 0.8391, beating both. On spam it made no difference. Same step, opposite results, depending on the task.
Vectorizers have a fit method, so the leakage rule applies to them too. Fitting on all data before splitting added 639 words to the vocabulary only because test documents helped build it: 17% of it at a 25% test split, rising to 79% at a 90% split. It changed AUC by only 0.0004 at the smaller split, but a leak's size depends on the setup, so follow the rule every time. Put the vectorizer in a Pipeline and the question goes away.
Look at how text is written, not just which words it uses. Nine style features beat a 3,802-word vocabulary on F1, and a random forest on those nine columns reached 0.9373. Spam has to include a phone number or price to be worth sending, and that survives most rewording. Check how the data was collected, though, since placeholders can make a signal look stronger than it is.
The metric decides which features look worth it. Adding style features to word TF-IDF moved AUC by about +0.003 and recall by nearly +0.09. Watching only AUC would have said it wasn't worth it. Remember that recall and F1 depend on the cutoff, so compare models at a matched cutoff when the decision matters.
Remove duplicates before modeling. Repeated messages, more than twice as common in spam as in ham, inflated AUC by a small but real amount, on a model already near its best. The fix is one line, so there's no reason to risk a bigger cost on a weaker model or messier data.
Words aren't the only unit to try. Character n-grams gave the best text-only result here, because SMS spelling is so messy. Hashing gives up a little accuracy and all interpretability so it never has to store a vocabulary, and it handled collisions well, because most words are rare. Its remaining gap to TF-IDF comes from skipping IDF weighting, not from collisions.
Check the spread, not just the average. An average of about 1.0 for a long-number feature sounds like "every spam message has one phone number." In fact, almost a quarter of spam had none. An average tells you the middle, not the spread.
Notebook 7 moves from creating columns out of text to creating them from columns already in the table: ratios, differences and groupby summaries that turn many rows into one.
This article goes with Notebook 6 of a feature engineering series built on the SMS Spam Collection and Sentiment Labelled Sentences datasets. Every code cell above runs exactly as shown in the notebook, with its real output.