Feature Engineering for Text Data
DuplicatesVocabularySparsityBag of wordsTF-IDFCleaningLeakageHand-craftedCombiningCharactersTest

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.

Datasets
SMS Spam Collection (5,574 messages) & Sentiment Labelled Sentences (3,000 reviews)
Stack
pandas · scikit-learn
Companion to
Notebook 6

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.

PythonImports and setup
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.')
Output
Ready.
PythonLoading the SMS data
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)
Output
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
PythonReading a few of each by eye, before writing any code
for lab in ['ham', 'spam']:
    print(f'--- {lab.upper()} ---')
    for m in sms.loc[sms['label'] == lab, 'message'].head(3):
        print('  ', m[:110])
    print()
Output
--- 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.

PythonWhat a lazy model can get away with
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')
Output
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.

PythonCounting duplicate messages, by class
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]}')
Output
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.

PythonMeasuring the actual inflation
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}')
Output
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.

PythonThe working set, from this point forward
sms = sms_dedup
X_text = sms['message'].values
y = sms['is_spam'].values

print(f'working set: {sms.shape}, spam rate {y.mean():.4f}')
Output
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.

PythonBuilding one by hand, on four short reviews
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))])
Output
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.

PythonTwo sentences that disagree about the food
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()))
Output
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
"the food was good but the service was not" "the food was not good but the service was" unigram vectors: IDENTICAL same 7 words, same counts, opposite meaning + bigrams: now they differ two specific phrase columns separate the pair

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.

PythonThe same pair, with bigrams added
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)
Output
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

PythonThe real document-term matrix, at full scale
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')
Output
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.

PythonTwo different kinds of frequency, and why mixing them up is a real trap
# 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')
Output
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.

PythonThe shape of the sparsity, and how the vocabulary grows
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')
Left: a spy plot of the top-left corner of the document-term matrix showing scattered isolated dots on a mostly white background. Middle: a log-log plot of word frequency showing a steep decline, with vertical lines marking where min_df=2 and min_df=3 cut off. Right: vocabulary size climbing steadily and not leveling off as more documents are added
Left: 120 messages by 400 words, almost all white. Middle: a few words dominate. Right: the vocabulary keeps growing.

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

PythonSweeping min_df and max_df
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}')
Output
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.

PythonThe n-gram range, measured rather than assumed
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}')
Output
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.

PythonThe floor, shown exactly on four toy reviews
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}')
Output
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.0000

service 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.

PythonThe same pattern on the real corpus
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:]))
Output
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)
PythonCounts against TF-IDF, on identical folds
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}')
Output
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.

PythonThe coefficients, read directly
# 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]))
Output
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 &lt;#&gt;, 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

PythonChecking scikit-learn's own stopword list for negation words
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}')
Output
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.

PythonThe standard recipe, applied to three pairs of opposite sentences
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()
Output
  '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: True

Three 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.

PythonHow often negation actually shows up, and what it's worth
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}')
Output
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.500

15.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.

PythonThree strategies, measured properly
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}')
Output
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.

PythonThe same comparison, on SMS spam
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}')
Output
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.

PythonThe wrong way against the right way, measured directly
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]))
Output
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
vec.fit(ALL_TEXT) before splitting vocabulary sees every document 639 columns exist only because test docs voted vec.fit(TRAIN_TEXT) inside a Pipeline vocabulary sees training rows only unseen test words quietly ignored, not memorized AUC moves only +0.0004 here at a 25% test split but 79% of the vocabulary leaks in at a 90% split

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.

PythonThe identical leak, at four different split sizes
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%}')
Output
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.

PythonPipeline enforcing the rule, so there's nothing left to reason about case by case
# 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}')
Output
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.

PythonNine style features, built and inspected before any modeling
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())
Output
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     23

Some 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.

PythonCorrelation with the label, and two features that separate the classes cleanly
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())
Left: horizontal bar chart of correlation between each style feature and is_spam, with n_longnum and digit_ratio highest and upper_ratio lowest. Middle: histogram of message length showing ham concentrated at shorter lengths and spam at longer. Right: histogram of digit ratio showing ham almost entirely at zero and spam spread across a wide range
Nine columns built only from writing style, with three of them plotted against the label.

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?

PythonNine columns against thousands
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))
Output
                      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.9823

Nine 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.

PythonA random forest on the identical nine columns
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}")
Output
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.

PythonStyle alone, words alone, and both together
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))
Output
              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.9757

This is the most important table in the notebook, because its four metrics tell very different stories depending on which one you look at.

metricword TF-IDF+ stylechange
ROC AUC0.98940.9927+0.0033
F1 (spam)0.85660.9065+0.0499
recall0.75960.8468+0.0872
precision0.98230.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.

PythonWord n-grams against character n-grams, on identical folds
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}')
Output
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.

PythonNo vocabulary stored anywhere, at four different widths
# 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.')
Output
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.9847
     262,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

PythonThe full leaderboard, cross-validated
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))
Output
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.9761
PythonThe same table, seen rather than just read
summary = 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}')
Left: bar chart of six models showing AUC bars nearly flat across all of them while F1 and recall bars vary noticeably. Right: the same six models plotted as change from the word TF-IDF baseline, showing recall and F1 climbing steadily while AUC stays close to zero
Six quite different feature sets. The blue AUC bars barely move. The orange and red bars do.

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.

PythonBoth candidates, on data neither has ever touched
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))
Output
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                0
PythonThe winner, examined in full
best_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']))
Output
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             146

The 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.