Part VII — Natural Language Processing

Chapter 25 · Text Analytics: Bag of Words and Beyond

Every dataset so far came as numbers. This one comes as words. The file holds 1,000 restaurant reviews. Each review has a label: liked or not liked. The task is to teach a model to read a new review and decide. The chapter starts with a map of NLP. The map shows where this chapter sits, and where the deep models begin. Then comes the core idea: turn text into numbers. A real email becomes a vector of 20,000 counters, one counter per word. After that, the pipeline runs on the real reviews: cleaning (with a live bug hunt — the day the stop-word list deleted "not" and turned "crust is not good" into praise), stemming, a matrix of 1,566 words cut to the 1,500 most useful, and Naive Bayes scoring 73%. Finally, the chapter goes beyond yes and no: a full section builds a positive, neutral and negative system — with a downloadable three-label corpus you can run end to end.

📖 Chapter 25 of 33 ⏱ ~110 min read Mostly Core Stable Volatile 📝 Quiz · 32 MCQs

🎯 Learning Objectives

1. From Words to Numbers

Text is the most common data in the world. But models need numbers, and text is not numbers. The dataset is Restaurant_Reviews.tsv. It holds 1,000 reviews, one per row, each labelled Liked = 1 or 0. The classes are perfectly balanced: 500/500. The question: will a new review praise us or criticise us? This is sentiment classification. It is supervised learning, like Part III. But the features are raw sentences.

The plan of this chapter is the plan of all classical NLP. Turn every review into a fixed-length vector of numbers. Then reuse every model already built in this course. Sections 2–4 build the ideas: the map of the field, and the bag-of-words intuition on a real email. Sections 5–8 then run the pipeline on the real reviews:

Raw review "Wow... Loved this!" Clean + stem wow love place Bag of words counts / TF-IDF Classifier NB / logistic / SVM / ANN word order is destroyed — but sentiment survives, because sentiment lives in the word choice
Figure 25.1 — The classical NLP pipeline: text → counts → classifier. The last box accepts any classifier. That flexibility is the point of Section 3.
📝
Note: the name "bag of words" is literal. The review becomes a bag of words. Word order is thrown away. Only the counts remain. "Not good" and "good not" give the same bag. This sounds like a fatal problem. Section 10 shows why it still works. Section 12 shows what it costs.

2. The Map of NLP — Where This Chapter Lives

Before the code, let us draw one picture. The picture organises the whole field. It also shows what this course will not build. Two circles overlap:

Natural Language Processing Deep Learning Deep NLP sequence- to-sequence translation · chatbots if/else chatbots audio-frequency speech recognition bag of words → logistic / NB CNN for text · BoW → neural net CNNs · RNNs · transformers (applied to anything) circle sizes are NOT to scale — a map of ideas, not of market share
Figure 25.2 — The map of the field. This chapter builds the model at bottom-left: bag of words plus a classical classifier. Part VIII gives the same bag to a neural network. The model then moves into the overlap.

Why draw the map? It sets honest expectations. The famous NLP applications — machine translation, image captioning, speech recognition, question answering, summarisation, chatbots — are sequence-to-sequence problems. They sit at the frontier of the field. This course builds the classical corner: sentiment classification. A bag of numbers plus a classifier from Chapters 11–15 already delivers real value here. And every concept in this chapter — tokenisation, vocabulary, sparsity, negation — transfers directly to the deep models later.

💡
Pro Tip — you use this map every day: your email app offers three ready replies under a message. That is sequence-to-sequence. Your search engine completes your half-typed question. That is NLP ranking. A review site tags a paragraph "positive". That is this chapter.

3. Classical vs Deep — Five Models on the Map

Let us put five real models on the map, one per region. They are all worth knowing. Even the ones we will never build.

ModelRegionHow it worksFate
1 · If/else chatbotsclassical NLPa hand-written list of expected questions. Each question has a fixed answer. Match the input, return the scriptthe chatbots of the 2000s. Brittle: one unexpected phrasing breaks them
2 · Audio-frequency speech recognitionclassical NLPsplit the sound wave into its frequencies (Fourier-style maths). Match the pattern against a prepared library. No neurons anywherereplaced by deep models. But a perfect example of NLP without neural networks
3 · Bag of words → classical classifierclassical NLPcount words into a vector. Classify with logistic regression or Naive Bayes — this chapter's buildstill the professional baseline for sentiment (Section 10)
4 · Bag of words → neural networkdeep NLPthe same vector, used as an input layer of 20,000 neurons in Chapter 26's networkthe bridge: same representation, deeper model
5 · CNN for text / seq2seqdeep NLP → seq2seqwords become embedding vectors (a matrix). Chapter 27's convolutions slide across it. Then the result is pooled and flattened, as with images. Then comes the prediction. seq2seq reads one sequence and writes anotherthe frontier — translation, chatbots, captioning

One key insight deserves its own paragraph. The bag of words is not classical or deep by itself. It is a representation. Give the vector to logistic regression: you have built classical NLP. Give the same vector to a neural network: you have built deep NLP. The difference is the learner, not the bag. That is why the pipeline in Figure 25.1 ends with an any-classifier box.

3.1 A first example: marking essays

Before the email, let us see the bag-of-words idea on the simplest possible example. Examiners mark essays. Each mark is a pass (1) or a fail (0). Each examiner also leaves a short comment:

CommentMark
"Great job"1 — pass
"Amazing work"1 — pass
"Well done"1 — pass
"Very well written"1 — pass
"Poor effort"0 — fail
"Could have done better"0 — fail
"Try harder next time"0 — fail

The bag of words puts all these words into one bag. Then it counts. How often does "amazing" appear in comments marked 1? How often in comments marked 0? "amazing" goes with 1. "poor" and "harder" go with 0. "work" appears on both sides. It carries no signal.

Now a new comment arrives: "Good job, keep it up." It has no mark. The model pulls each word out of the bag and checks its history. "good" — mostly seen with 1. "job" — mostly with 1. "keep" and "up" — mostly with 1. Most words point to a pass. So the model predicts: pass.

That is the whole idea. No grammar. No word order. Just a count of which words go with which outcome. Everything else in this chapter is this idea, done carefully at scale.

recorded speech = one messy wave decompose …component frequencies… match the pattern against a prepared library → "that combination of frequencies = this word" pure mathematics — not one neuron involved
Figure 25.3 — Model #2: speech recognition by frequency components (schematic).

4. The Email Experiment — a 20,000-Dimension Intuition

Let us learn bag of words with a real email. A friend writes: "Hello Cyril, checking if you're back in Oz. Let me know if you're around and keen to sync… I defo could use some of your creative thinking… Cheers, Vee." ("Oz" means Australia. "defo" is short for definitely.) Under the message, the mail app offers three ready replies: "Yes, I'm around" · "I'm back" · "Sorry, I'm not". That is sequence-to-sequence, working on a real phone. We start with a simpler question. Can a model learn to reply yes or no? That small goal opens the door to all of NLP.

4.1 The vector, and why 20,000 slots

The model's whole memory of language will be one long vector of counters. At the start, every counter is zero. How many slots? Let us do the arithmetic with real numbers (commonly cited figures):

NumberWhat it counts
171,476entries in the Oxford dictionary (with obsolete and derivative forms) — the language's full inventory
20,000–35,000words known by an average adult native speaker
≈10,000 / ≈5,000words known by an average native speaker at age 8 / at age 4 — vocabulary grows with age
≈3,000words that cover about 95% of common text. Just 1.75% of the dictionary does 95% of the work
20,000the chosen vector size. It is larger than any speaker's working vocabulary. So almost no real word is left without a slot

Every word gets a fixed position. For example, "if" lives in slot 7. "badminton" and "table" live in other slots. A word keeps its slot forever. Fixed positions are what let us compare vectors, add them, and feed them to any classifier.

4.2 Three reserved slots

Slots 1 and 2 are reserved. They hold the markers sos (start of sentence) and eos (end of sentence). They work like punctuation for sequence models. They will matter in Part VIII, when networks read text in order. The last slot is the unknown bucket. Every word the vector has never met goes there.

4.3 Pouring the email in

sos eos 2 1 2 1 3 reserved "if" ×2 "hello" ×1 "you" ×2 "checking" ×1 unknown bucket … 20,000 slots, almost all still zero … Cyril, Oz and Vee are not in the 20,000 — all three land in the special bucket (count 3)
Figure 25.4 — Vee's email inside the vector (illustrative slot positions). Six slots change. 19,994 stay zero. This is sparsity, met for the first time.

One more detail. Punctuation can get a slot too. A comma is not a word. But it is a character in the text. In an uncleaned vector, it gets its own counter. Section 5 deletes punctuation before counting. That is one reason cleaning comes first.

4.4 Teaching the vector to say yes or no

Now comes the supervised step used in every chapter since Chapter 4: gather history. Take real pairs from a (theoretical) inbox — "Have you read about Hinton's capsule networks?" → "No", "Did you like that recipe?" → "Yes", "Coming to dinner tonight?" → "Yes", "Service your car with us again?" → "No", "Coming to Australia in December?" → "Yes". Thousands of such pairs. Each email becomes the same 20,000-slot vector. Every vector has the same length and the same slot meanings. So any classifier in this course can read them:

logistic regression on the vectors  =  classical NLP
the same vectors as a 20,000-neuron input layer  =  deep NLP
Section 3's point, now concrete: the bag does not care which model reads it. Its limit is also clear. The output is one bit: yes or no. A conversation needs a model that writes a sequence. That is the seq2seq corner of the map. That is where this course hands over to specialists.

One honest note. Real inboxes do not hold clean yes/no labels. People write full replies. To build this dataset, someone must read every reply and judge: was that a yes or a no? That is the real groundwork. This inbox is a theoretical example. Nobody would build it for their own mail. But the method is exactly right.

📝
Note: even small everyday demos are NLP. A search engine turns "how many words are in the English language" into related questions. The field is working on you while you study it. Once you see the map, you see it everywhere.

5. Cleaning & the Corpus — with a Bug Hunt

Now we move from intuition to the 1,000 real reviews. The first review in the dataset, exactly as written: "Wow... Loved this place." Cleaning has five steps. We will build them one line at a time:

StepOperationResult
1 · Scrubre.sub('[^a-zA-Z]', ' ', …)Wow   Loved this place
2 · Lowercase.lower()wow   loved this place
3 · Tokenise.split()[wow, loved, this, place]
4 · Stopwordsdrop the filler list (this…)[wow, loved, place]
5 · Stemloved → love (Porter)[wow, love, place]

5.1 The re library — cleaning with patterns

The first import in the cleaning code is import re. The name re is short for regular expression. A regular expression is a pattern. We write the pattern once, and the library uses it to find every matching character in the text.

Why do we need it? Look at the raw reviews. They contain dots, commas, exclamation marks, numbers and other symbols. These characters do not tell us if a review is positive or negative. So we remove them. The re library removes them with one function:

review = re.sub('[^a-zA-Z]', ' ', dataset['Review'][i])
The function is re.sub(). The name means "substitute". It takes three inputs, in this order:
1. the pattern — which characters to find. Here it is '[^a-zA-Z]'.
2. the replacement — what to put in place of each match. Here it is ' ' (one space).
3. the text — where to search. Here it is review number i from the dataset.
The function replaces all matches, not only the first one.

Now read the pattern [^a-zA-Z] piece by piece. Each symbol has one simple meaning:

SymbolNameMeaning
[ ]character setmatches one character from the list inside the brackets
^NOT (when placed first, inside the brackets)reverse the list — match any character that is not in it
a-zrangeall small letters, a to z
A-Zrangeall capital letters, A to Z
Full pattern: "any one character that is not a letter" — so it matches every dot, comma, digit and symbol, and nothing else

Here is the pattern working on real text. The outputs below come from running the exact code:

Input textCharacters the pattern findsOutput text
"Wow... Loved this place."the three dots, the final full stop, and the spaces (a space is also "not a letter", so it becomes a space again — no change)"Wow    Loved this place "
"Loved,this place #1!"the comma, the hash sign, the digit 1, the exclamation mark"Loved this place    "

Notice two things in the results.

First: we replace punctuation with a space, not with nothing. If we delete the comma in "Loved,this" completely, the two words join into one: "Lovedthis". That single word has no meaning, so the model cannot use it. A space keeps the words apart.

Second: the extra spaces are not a problem. Step 3 (.split()) breaks the text at any group of spaces. So "Wow    Loved" gives the same two words as "Wow Loved".

⚠
Warning — the two meanings of ^: inside [ ], the sign ^ means NOT. Outside [ ], the same sign means "the text must start here". For example, '^Wow' matches "Wow" only when it is the very first word of the text — never in the middle. One sign, two different jobs. Keep the brackets.

One last point. The re library only cleans the text. It does not do stemming and it does not remove stop words. Those two jobs belong to the next library, NLTK.

5.2 Stop words — the words we drop

NLTK gives us two tools here: the stop words and the stemmer. Stop words first. A stop word is a word with no sentiment. It cannot help us decide positive or negative. Examples: the articles "the", "a", "an". The pronouns "I", "you", "he", "she", "we", "they". Small words like "is", "this", "that".

Look at "Wow... Loved this place." The word "this" is a stop word. Remove it. The meaning does not change. "Loved" stays. It carries the sentiment. So the cleaned review keeps "wow love place" and drops only "this".

Why remove stop words? Two reasons. First, they carry no signal. Second, every kept word becomes a column. A few hundred stop words would add a few hundred useless columns. In code, we keep a word only if it is not in the stop-word list: [ps.stem(word) for word in review if not word in set(all_stopwords)]. NLTK ships the list ready-made: stopwords.words('english').

5.3 The bug hunt: the day "not" disappeared

Now print the cleaned corpus and look at it. Review 2 in the raw data reads "Crust is not good.". The printed corpus says:

expected: "crust not good"  ·  printed: "crust good"
The stop-word list has deleted "not". To the list, "not" is just another short filler word. To sentiment, it is the whole sentence. "crust good" and "crust not good" are opposite reviews. But their bags are almost the same. The fix is one line: all_stopwords.remove('not'). After it, the corpus prints correctly. There is one more trap: contractions like "isn't" are also in the list. The lesson: print your intermediate data. The bugs that cost you never raise an error.

The fix matters beyond review 2. Review 3 in the raw data reads "Not tasty and the texture was just nasty." Another negative review. With "not" restored, its cleaned form is "not tasti textur nasti" — the negation survives. Without the fix, this review would also look positive to the bag. The same one-line bug corrupts every negated review in the corpus.

5.4 Stemming vs lemmatization

Stemming (Porter — used here)Lemmatization
MethodChops suffixes by rulesDictionary + grammar lookup
loved →lovelove
studies →studi (not a word)study
better →bettergood
Why it matterslove and loved would otherwise be two redundant columns of the sparse matrixsame goal, cleaner merges, slower

Why clean at all? Here is the reason: every word that survives becomes a column. Stemming merges "love", "loved" and "loving" into one column. Stop-word removal deletes a few hundred columns that can never carry sentiment. The matrix stays as small as the meaning allows.

6. The Bag of Words

The corpus is clean. The next step is vectorisation. Collect the vocabulary. Then represent each review as a row of word counts. Build a small one by hand first. Three toy reviews. The vocabulary is sorted alphabetically:

Reviewgoodgreatnot
"good great"110
"not good"101
"good good great"210

That is the whole representation: a document-term count matrix. One more name to learn: the step of taking every word from every review and giving each word its own column is called tokenisation. Now the real run on the 1,000 cleaned reviews:

6.1 The 1,566 → 1,500 decision

Here is a good habit. Build the matrix with no vocabulary limit first. Count the columns with len(X[0]). Only then choose the cap. Re-run for real on this exact corpus:

len(X[0]) = 1,566 columns  →  CountVectorizer(max_features=1500)  →  1,500 columns
The uncapped vocabulary has 1,566 words. This canonical run reproduces the number exactly. Why drop any? Some words are not stop words, but still carry no sentiment. Names like Steve and Rick. Words like texture or holiday. Each appears in one or two reviews only. They cannot carry general sentiment. They are noise, in the Chapter 12 curse-of-dimensionality sense. The trick: keep only the most frequent words. The 66 dropped columns are exactly the 66 rarest. One warning: do not cut too far. At 1,000 words, useful words start to disappear too.

Two words about the code. fit_transform does two jobs. fit collects the words: it reads the corpus and learns the vocabulary. transform counts: it fills one column per word for every review. Then .toarray() converts the result into a plain 2D array. That is the format sklearn models expect.

QuantityValue (real)Reading
Matrix shape1,000 × 1,500One row per review. One column per kept word
Sparsity99.6% zerosEach review uses about 6 of the 1,500 words. Section 4's email vector, shrunk to toy scale
Sample featuresabsolut, acknowledg, actual, …Stemmed forms. The vocabulary is the word list of the stemmed corpus
💡
Pro Tip — sparse is normal: 99.6% zeros is normal for text. The vocabulary has thousands of words. A review uses only a few of them. Production systems store this as a sparse matrix: only the positions of the non-zeros are kept. sklearn's vectorisers produce exactly that. The .toarray() call in Listing 25.1 expands it, because GaussianNB wants dense input.

7. TF-IDF: Weighing Words

Raw counts have a weakness. Every word counts the same. But words are not equally informative. "great" appears in half the reviews. It separates little. "rancid" appears in nine reviews. It separates a lot. TF-IDF — term frequency × inverse document frequency — fixes this. It re-weights every cell:

w(t, d)  =  tf(t, d)  ×  [ ln( (1+n) / (1+df(t)) ) + 1 ]
tf = the count in this document. df = how many documents contain the word. n = the number of documents. A common word has df ≈ n, so idf ≈ 1: almost no boost. A rare word gets a large idf. The +1s are sklearn's smoothing. They prevent division by zero for unseen words.

Now verify by hand on the toy corpus (n = 3 documents; every number below is real library output). great appears in 2 documents → idf = ln(4/3) + 1 = 1.29. not appears in 1 → idf = ln(4/2) + 1 = 1.69. good appears in all 3 → idf = ln(4/4) + 1 = 1.00. Then multiply by the counts:

Reviewgood (idf 1.00)great (idf 1.29)not (idf 1.69)
"good great"1.001.290
"not good"1.0001.69
"good good great"2.001.290

Same matrix, smarter weights. "not" is rare and full of sentiment. It now out-weighs "great" in its document. TF-IDF repeats the trick of Chapter 21's lift: divide away what is common, keep what is informative.

⚠
Warning — an honest preview: TF-IDF is not always better. On this dataset (Section 8) it scores slightly lower than raw counts with the chosen classifier. That is a real result. Understand it, do not hide it. The best representation depends on the model.

8. Naive Bayes Returns — and the Beat-73% Challenge

Naive Bayes returns from Chapter 15, as promised. Text is its historic home. Thousands of sparse count features are exactly what the word-by-word probability product was built for. (The classic spam filter uses Multinomial NB.) This chapter uses GaussianNB. The split is 800 train / 200 test, seed 0. The real results:

ModelConfusion matrixAccuracyPrecisionRecallF1
NB on raw counts[[55, 42], [12, 91]]0.7300.6840.8830.771
NB on TF-IDF[[57, 40], [16, 87]]0.7200.6850.8420.757

Two things to notice. First, the errors lean toward false alarms. Only 12 liked reviews were missed (of 103). But 42 reviews got false praise. This is the negation problem of Section 5, made visible: a "not good" bag contains both "not" and "good". It often tips toward liked. Second, the TF-IDF dip. GaussianNB models each feature with a bell curve. TF-IDF values have a long tail, not a bell. The fit is worse than with raw counts. The lesson is not "TF-IDF is bad". The lesson is: match the representation to the model.

8.1 The first three predictions — and the challenge

The build ends with a moment worth keeping. The predictions print. The first three test reviews are all wrong. Each was predicted positive. Each is actually negative. And note: these are random reviews from the test set. They are not the first rows of the file. No test review was seen during training. Do not panic. Scroll on. By the end of the list, the correct answers dominate. The confusion matrix gives the verdict: 73.0%. And now the challenge of this course: beat 73%. Try other classifiers. Improve the cleaning. Remove "isn't" from the stop words.

We accept. Real runs. Same bag, same split:

Model (same 1,000 × 1,500 bag, same split)Confusion matrixAccuracyPrecisionRecallF1
Naive Bayes (the 73% to beat)[[55, 42], [12, 91]]0.7300.6840.8830.771
Logistic regression (Ch 11)[[80, 17], [28, 75]]0.7750.8150.7280.769
Linear SVM (Ch 13)[[80, 17], [27, 76]]0.7800.8170.7380.776

Challenge answered: 78.0%. But look at the other columns. The rivals win on accuracy and precision (0.817 vs 0.684). They lose on recall (0.738 vs 0.883). NB catches 91 of 103 positive reviews. The SVM catches 76. This is Chapter 18's whole story in one table. "Best" depends on the costs. A site that auto-promotes positive reviews wants precision. A brand hunting for unhappy customers wants recall.

💡
Pro Tip — the challenge stays open: 78.0% is the number to beat. Three suggestions to try: remove contractions ("isn't") from the stop list, add bigrams (ngram_range=(1, 2)), try MultinomialNB. The hands-on below scores them.
📋 Stable content — Reviewed: October 2026

9. Implementation in Python

import numpy as np
import pandas as pd
import re
import nltk
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer

dataset = pd.read_csv('Restaurant_Reviews.tsv', delimiter='\t', quoting=3)

nltk.download('stopwords')                       # one-time corpus fetch
all_stopwords = stopwords.words('english')
all_stopwords.remove('not')                      # THE line: keep negation
                                               # (the bug hunt of Section 5)

ps = PorterStemmer()
corpus = []
for i in range(0, 1000):
    review = re.sub('[^a-zA-Z]', ' ', dataset['Review'][i])  # not-a-letter → ' '
    review = review.lower().split()
    review = [ps.stem(word) for word in review
              if word not in set(all_stopwords)]
    corpus.append(' '.join(review))
# corpus[0] == 'wow love place';  corpus[1] == 'crust not good'  (bug fixed)

from sklearn.feature_extraction.text import CountVectorizer
cv_full = CountVectorizer()                      # step 1: NO cap — count first
X_full = cv_full.fit_transform(corpus).toarray()
print(len(X_full[0]))                            # 1566 columns (the real count)

cv = CountVectorizer(max_features=1500)          # step 2: keep the 1,500
X = cv.fit_transform(corpus).toarray()           # (1000, 1500), 99.6% sparse
y = dataset.iloc[:, -1].values

from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB
from sklearn.metrics import confusion_matrix, accuracy_score
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.20, random_state=0)
classifier = GaussianNB()
classifier.fit(X_train, y_train)
y_pred = classifier.predict(X_test)
print(confusion_matrix(y_test, y_pred))          # [[55 42]
                                                 #  [12 91]]
print(accuracy_score(y_test, y_pred))            # 0.73

# ---- the beat-73% challengers, same bag, same split
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
for challenger in [LogisticRegression(max_iter=1000),
                   LinearSVC(max_iter=5000)]:
    challenger.fit(X_train, y_train)
    print(accuracy_score(y_test, challenger.predict(X_test)))
    # 0.775 … then 0.780

Listing 25.1 — The complete pipeline (source: natural_language_processing.py, plus the vocabulary-count step and the challenge runs).

9.1 The source file, line by line

Listing 25.1 above is our enhanced version. The original source file is the same walk with fewer frills. Here it is again, block by block — every line, in order.

Lines 3–6 · The imports.

import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

NumPy for arrays. Pandas for the table. And matplotlib — which this task never uses. The course template carries it in every file, because other sections plot. Harmless. But worth noticing: not every import earns its place.

Line 9 · Load the data.

dataset = pd.read_csv('Restaurant_Reviews.tsv', delimiter='\t', quoting=3)

Three inputs, left to right. The file name. The delimiter: the columns are separated by tab characters, not commas — so '\t'. And quoting=3: the number 3 is the code for QUOTE_NONE, meaning "treat every character literally, there are no quote marks to respect". Reviews are full of apostrophes; this flag keeps them safe. (The mini-project's financial_headlines.tsv is read the same way.)

Lines 12–16 · The cleaning imports.

import re
import nltk
nltk.download('stopwords')
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer

re does the pattern cleaning (Section 5.1). nltk is the natural language toolkit. download('stopwords') fetches the stop-word list once — after the first run, this line just confirms it is there. Then two precise imports: the stop-word list from the corpus module, and the PorterStemmer class from nltk.stem.porter.

Lines 17–18 · The corpus and the loop.

corpus = []
for i in range(0, 1000):

An empty list, ready to hold 1,000 cleaned reviews. The loop walks review indices 0 to 999 — one review at a time, clean it, store it. The 1,000 is hardcoded; range(len(dataset)) would survive a different file size. A template habit, not a bug.

Lines 19–21 · Clean one review.

review = re.sub('[^a-zA-Z]', ' ', dataset['Review'][i])
review = review.lower()
review = review.split()

Three steps, three lines. Replace every non-letter with a space (Section 5.1 reads this pattern character by character). Lowercase everything, so "Wow" and "wow" count as one word. Split the string into a list of words. After line 21, review is a list like ['wow', 'loved', 'this', 'place'].

Lines 22–24 · The stemmer and the stop words.

ps = PorterStemmer()
all_stopwords = stopwords.words('english')
all_stopwords.remove('not')

Create the stemmer. Load the English stop-word list. Then the line this chapter waited for: remove('not') — the one-line fix from the bug hunt (Section 5.3). Without it, "crust is not good" cleans to "crust good". One note: the source creates the stemmer inside the loop, so it is rebuilt 1,000 times. It works. Listing 25.1 moves it above the loop, where it is built once.

Line 25 · The one-line workhorse.

review = [ps.stem(word) for word in review if not word in set(all_stopwords)]

Read it right to left. Take each word in the review. Keep it only if it is not in the stop-word set (set() makes the membership test fast). Stem whatever survives: "loved" → "love". One line, three jobs. The result is the cleaned list — ['wow', 'love', 'place'] for review 1.

Lines 26–28 · Join, append, print.

review = ' '.join(review)
corpus.append(review)
print(corpus)

Glue the words back into one string, separated by single spaces (an empty separator would fuse them into "wowloveplace"). Add the finished review to the corpus. And print the whole corpus — the very print that exposed the missing "not". Printing intermediate results is not debugging decoration. It is how the bug was found.

Lines 31–34 · The bag of words, and y.

cv = CountVectorizer(max_features=1500)
X = cv.fit_transform(corpus).toarray()
y = dataset.iloc[:, -1].values

CountVectorizer turns the corpus into the count matrix: fit collects the vocabulary, transform counts, toarray makes the dense 2D array models expect (Section 6.1). The source caps at 1,500 directly. Listing 25.1 counts first — the uncapped run prints 1,566, and then the cap chooses the 1,500 most frequent (Section 6.1's good habit). And y takes the last column: Liked, 0 or 1.

Lines 37–38 · The split.

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.20, random_state=0)

800 reviews to learn from, 200 to be judged on. random_state=0 fixes the shuffle — every run gets the same split, so every number in this chapter reproduces exactly.

Lines 41–43 · Train.

classifier = GaussianNB()
classifier.fit(X_train, y_train)

Create the Naive Bayes model from Chapter 15. Fit it on the training set. That is the whole training step — one line. Inside, it learns one probability table per word (Section 8).

Lines 46–47 · Predict, and the side-by-side print.

y_pred = classifier.predict(X_test)
print(np.concatenate((y_pred.reshape(len(y_pred),1),
                     y_test.reshape(len(y_test),1)),1))

Predict all 200 unseen reviews. Then the print from Section 8.1: reshape(len, 1) turns each flat vector into one tall column, and np.concatenate(..., 1) glues the two columns side by side — predictions on the left, truth on the right. This is the screen where the first three rows are all wrong, and the challenge begins.

Lines 50–53 · The verdict.

cm = confusion_matrix(y_test, y_pred)
print(cm)
accuracy_score(y_test, y_pred)

The confusion matrix prints [[55 42] [12 91]]. The last line asks for the accuracy: 0.73. In a notebook the bare expression displays its value; in a plain script you would wrap it in print(). Section 10 reads this matrix cell by cell.

📝
Three template habits worth noticing: (1) matplotlib is imported but unused — templates carry their imports everywhere. (2) the stemmer is created inside the loop — correct but wasteful; build tools once, above the loop. (3) range(0, 1000) hardcodes the row count — range(len(dataset)) travels to any dataset. None of these are errors. All three are worth unlearning early.
# The Section 7 upgrade — TF-IDF, one word apart
from sklearn.feature_extraction.text import TfidfVectorizer
tv = TfidfVectorizer(max_features=1500)
X_tfidf = tv.fit_transform(corpus).toarray()     # same shape, weighted cells

# Modern upgrades worth knowing
# CountVectorizer(ngram_range=(1, 2))            # "not good" becomes one feature
# from sklearn.naive_bayes import MultinomialNB  # the natural pair for counts
# TfidfVectorizer(..., norm='l2')                # sklearn default: unit-length rows

Listing 25.2 — TF-IDF and the one-parameter upgrades.

💡
Pro Tip: the cleaning loop is the slow part on large corpora. And Chapter 3's leakage rule applies here too. Fit the vectoriser (the vocabulary) on the training reviews only. Then transform the test reviews. The listing above fits on everything. That is defensible with a fixed vocabulary, but sloppy in principle. Pipelines make the correct order automatic.

10. Reading the Results

The full confusion matrix, cell by cell (200 test reviews; 103 are actually liked):

Predicted
ActualNot likedLiked
Not liked (97)55 TN — correctly ignored42 FP — false praise
Liked (103)12 FN — missed fans91 TP — caught

The 42 false positives are the bag's typical error. These reviews contain many positive-sounding words. But they mean the opposite. Examples: "not good", "used to be great", "great location, terrible food". Chapter 18's toolkit reads this matrix clearly. Recall is 0.883: only 12 fans missed. The price is precision 0.684. Is that trade right? It depends on what a false "liked" costs. A missed chance to fix a problem? A wrongly promoted review? Note also the class balance: 50/50. A coin flip already scores 50%. So 0.73 is a real 23-point lift over chance.

The challengers sharpen the picture. The linear SVM's matrix is [[80, 17], [27, 76]]. It moves 25 customers from the false-alarm column to the correct-rejection column. Precision rises to 0.817. The cost: 15 more fans missed. Same data. Same representation. Three defensible operating points. The model choice is a business choice.

🌎
Real World: this exact pipeline — clean, bag of words (or TF-IDF), Naive Bayes or logistic regression — ran the sentiment dashboards and spam folders of the 2000s. It is still the right baseline today. It builds in minutes. It needs no GPU. On tidy reviews it is often within a few points of the big models. Ship it first. Upgrade only when it clearly leaves value behind.

One last thought before the outlook. We just trained a machine to read English. It read 1,000 reviews. It learned which words praise and which words complain. And it answers correctly 73 times out of 100. A few decades ago this was a hard research problem. Today it is twenty lines of Python.

11. Positive, Neutral, or Negative

Liked or not liked. Yes or no. Every classifier in this chapter is forced to choose one of two answers. But many real reviews are neither happy nor angry. "The food was okay. Nothing special." That is a lukewarm review. A business often wants three piles: promote the positives, fix the negatives, skip the neutrals. Let us build the third pile. There are two ways.

11.1 Way 1: a dataset with three labels

The clean way needs data that already carries three labels. Star ratings are the classic source. Map 1–2 stars to negative, 3 stars to neutral, 4–5 stars to positive. Now y holds three values instead of two:

# stars -> three classes
y = np.where(stars >= 4, 2,            # 4-5 stars -> 2 (positive)
    np.where(stars == 3, 1, 0))        # 3 -> 1 (neutral), 1-2 -> 0 (negative)

Nothing else changes. GaussianNB, logistic regression and most sklearn classifiers accept three classes without a single new parameter. The confusion matrix becomes 3×3. Precision and recall are computed per class, one row each, with Chapter 18's tools.

One honest limit: Restaurant_Reviews.tsv has no neutral label. So this section ships its own small corpus with star ratings. We built it from word pools: 12 positive words ("great", "delicious"…), 9 lukewarm words ("okay", "average"…), 12 negative words ("terrible", "bland"…), plus everyday filler. Half of the neutral reviews use lukewarm words. The other half are mixed: one praise and one complaint in the same review. 300 reviews per class, 900 rows, fixed random seed. Download it and reproduce every number below yourself:

📝
Download the data: synthetic_reviews.csv — 900 reviews + star ratings, 33 KB. The link is a relative path, so it works from your local copy of the book and from the live site. Put the file next to your script and run Listing 25.3 as-is.
# ---- Listing 25.3: three classes, the full pipeline ----
# data: synthetic_reviews.csv  (900 reviews, star ratings 1-5)
import numpy as np, pandas as pd, re, nltk
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer
nltk.download('stopwords', quiet=True)
sw = stopwords.words('english'); sw.remove('not')      # keep negation
ps = PorterStemmer()

ds = pd.read_csv('synthetic_reviews.csv')              # <- downloaded above
corpus = []
for i in range(len(ds)):
    r = re.sub('[^a-zA-Z]', ' ', ds['Review'][i]).lower().split()
    corpus.append(' '.join(ps.stem(w) for w in r if w not in set(sw)))

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import train_test_split
X = CountVectorizer().fit_transform(corpus).toarray()  # (900, 44)
stars = ds['Stars'].values
y = np.where(stars >= 4, 2, np.where(stars == 3, 1, 0))  # 0 neg, 1 neu, 2 pos
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0)               # 720 train / 180 test

from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, accuracy_score, classification_report

for model in [GaussianNB(), LogisticRegression(max_iter=1000)]:
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    print(type(model).__name__,
          'accuracy:', round(accuracy_score(y_test, y_pred), 4))
    print(confusion_matrix(y_test, y_pred))            # a 3 x 3 matrix
    print(classification_report(y_test, y_pred,
          target_names=['neg (0)', 'neutral (1)', 'pos (2)']))

Listing 25.3 — The full three-label pipeline. Download the CSV, run this file unchanged, and every number below prints on your screen. After cleaning, the whole corpus speaks only 44 words.

The test split holds 180 reviews: 58 negative, 58 neutral, 64 positive. The real results:

True ↓ / Predicted →GaussianNB (accuracy 0.794)Logistic regression (accuracy 0.978)
negneuposnegneupos
Negative (58)57015800
Neutral (58)1624180580
Positive (64)20624060

Read the middle row. That row is the whole three-class story. GaussianNB catches only 24 of the 58 neutrals — recall 0.414 (its precision is 1.000: what it calls neutral is neutral). The 34 misses are the mixed reviews. Logistic regression catches all 58. When a class has its own vocabulary ("okay", "average"), a model that can weight words one by one learns it easily.

Logistic regression still makes 4 mistakes. All four are the same pattern — a positive review with an honest complaint: "The food was perfect, although the meal was disgusting.", "The atmosphere was best, although the service was disappointing." The strong complaint word out-weighs the praise. The bag counts both sides; it cannot weigh "although". One honest caveat the other way: 0.978 is high partly because the corpus is synthetic and speaks only 44 words. Real star-rated corpora (Yelp, Amazon) are far messier — expect less.

11.2 Way 2: the neutral band — the model abstains

The second way works on our dataset today. Every classifier can output a probability, not just a class. The call is predict_proba. A probability near 1 means: the model is sure the review is positive. Near 0: sure it is negative. And near 0.5? The model does not know. So we give those reviews their own label:

p ≥ 0.60  →  positive   ·   p ≤ 0.40  →  negative   ·   in between  →  neutral
p = the model's predicted probability that the review is positive. The two thresholds (0.40 and 0.60) form the neutral band. Inside the band, the model abstains: "not sure — send this one to a human".

Which model's probabilities can we trust? Test both, on the same 200 test reviews:

ModelMedian probabilityProbabilities outside 0.1–0.9Reviews inside the 0.4–0.6 band
GaussianNB1.0000100%0
Logistic regression0.4614%49

GaussianNB is overconfident. Its probabilities collapse to almost 0 or almost 1. Every single one. The median is 1.0000. So the band catches nothing — at any width you choose. Logistic regression gives smooth, honest probabilities. Its median is 0.46. The band catches 49 of the 200 reviews. Lesson: the abstain trick needs a model with usable probabilities.

11.3 The real results, and the trade-off

Logistic regression with the band at 0.40/0.60 (real run, same split as Section 8):

NegativeNeutralPositiveAnsweredAccuracy on answered
814970151 of 200 (75.5%)0.834

Compare. The same model forced to answer everything scores 0.775 (Section 8). With permission to skip 49 unsure reviews, it answers the remaining 151 at 0.834. And the band width is a dial. Turn it:

Band (half-width)ThresholdsNegNeutralPosCoverageAccuracy on answered
0.000.50 / 0.50108092100%0.775
0.050.45 / 0.5597178691.5%0.809
0.100.40 / 0.6081497075.5%0.834
0.150.35 / 0.6568706265.0%0.877
0.200.30 / 0.7060875356.5%0.938
0.300.20 / 0.80391233838.5%0.961
the neutral-band trade-off (logistic, 200 test reviews) 100% 80% 60% 40% 0 0.05 0.10 0.15 0.20 0.25 0.30 neutral band half-width → 38.5% 96.1% accuracy on answered  ↑ coverage (share answered)  ↓
Figure 25.5 — The staircase: widen the band and the model answers fewer reviews (coverage falls) but answers them better (accuracy on the answered reviews rises). Every point is a real run.

Read the staircase. Answer everything: 0.775. Skip the 17 most unsure reviews: 0.809. Skip 87: 0.938 — but you now answer only 113 of 200 reviews. There is no free lunch. Better answers cost coverage. The right width is a business choice, exactly like the cost-based thresholds of Chapter 18. An auto-reply system can live at 0.10. A system that escalates reviews to a busy manager might sit at 0.20.

11.4 What lands in the band

The borderline reviews are worth reading (real test reviews, real probabilities):

pReviewTrue label
0.400"You cant go wrong with any of the food here."1 — positive
0.401"Boy was that sucker dry!!."0 — negative
0.404"They brought a fresh batch of fries and I was thinking yay something warm but no!"0 — negative
0.406"Def coming back to bowl next time"1 — positive

These are exactly the reviews a word-count model should not decide. An idiom ("can't go wrong" — the bag sees "wrong"). A mixed review (warm words, then "no!"). A thin review with almost no sentiment words. The band routes all of them to a human.

And the most confident mistake of the whole test set is Section 5's lesson again. The review "We made the drive all the way from North Scottsdale... and I was not one bit disappointed!" is positive. The model gives it p = 0.007 — negative, with near-total confidence. "not disappointed" carries both "not" and "disappointed". The bag counts both. Negation fools the counter even when the counter is confident. (Its opposite: "Loved it...friendly servers, great food, wonderful and imaginative menu." gets p = 0.998 — and is right.)

11.5 Checking the band against true neutral labels

The synthetic corpus has real neutral labels. So it can answer the question the restaurant data cannot: when the band says "neutral", how often is the review truly neutral? Train the binary model on positive and negative reviews only. Then predict probabilities for all 180 test reviews — neutral reviews included, exactly like a real deployment where lukewarm reviews arrive unannounced:

# continues from Listing 25.3: X_train, X_test, y_train, y_test
binary = y_train != 1                       # train on pos + neg only
lr2 = LogisticRegression(max_iter=1000).fit(X_train[binary], y_train[binary])
p = lr2.predict_proba(X_test)[:, 1]         # P(positive) for every test review

band = (p >= 0.40) & (p <= 0.60)            # the neutral band
for lab, name in [(0, 'neg'), (1, 'neu'), (2, 'pos')]:
    caught = int(((y_test == lab) & band).sum())
    total  = int((y_test == lab).sum())
    print(f'true {name}: {caught} of {total} in the band')

The real answer (180 test reviews):

In the bandShare of the bandShare of the class caught
Truly negative12.0%1 of 58 (1.7%)
Truly neutral4588.2%45 of 58 (77.6%)
Truly positive59.8%5 of 64 (7.8%)
Total51100%51 of 180 answered → 129 abstained

The band mostly works. 45 of the 51 reviews inside it (88.2%) are truly neutral. And 45 of the 58 true neutrals (77.6%) land inside. Look at what the band actually caught: "The menu was standard and acceptable." (p = 0.589). "The service was ordinary and average." (p = 0.488). "The restaurant was usual." (p = 0.455). The binary model never saw these words in training. They push the probability nowhere. So it drifts to the middle — and the band catches them.

But 13 true neutrals escape, and almost all of them are the mixed reviews: "The food was loved, but dinner was stale." got p = 0.666 — confidently positive. "The staff was great, but the place was slow." got p = 0.260 — confidently negative. A mixed review carries two real signals. The model adds them and takes a side.

⚠
Warning — what the band can and cannot catch: the band catches reviews with no signal — lukewarm words the model has never met, so the probability drifts to 0.5. It does not catch reviews with two signals ("great food, terrible service"): those get a confident label, rightly or wrongly. To catch both kinds, use Way 1: train on a corpus with real neutral labels, so the model learns the neutral vocabulary.
# ---- Listing 25.4: Way 2 on the restaurant reviews (no neutral labels) ----
# continues from Listing 25.1: X_train, X_test, y_train, y_test are ready

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression(max_iter=1000).fit(X_train, y_train)
proba = lr.predict_proba(X_test)[:, 1]       # P(positive) per test review

LO, HI = 0.40, 0.60                          # the band; move it as needed
pred3 = np.where(proba >= HI, 1,             # confident positive
       np.where(proba <= LO, 0, 2))          # confident negative; 2 = neutral

answered = pred3 != 2
print(np.unique(pred3, return_counts=True))  # [0 1 2] -> [81 70 49]
print('coverage:', answered.mean())          # 0.755  (151 of 200 answered)
print('accuracy on answered:',
      (pred3[answered] == y_test[answered]).mean())   # 0.834

# why not GaussianNB? look at its probabilities:
nb_probas = classifier.predict_proba(X_test)[:, 1]
print(np.median(nb_probas))                 # 1.0 — every probability extreme

Listing 25.4 — The neutral band on the restaurant corpus: the model abstains when unsure (Way 2, when no neutral labels exist). All printed numbers are from the canonical run.

💡
Take it to finance: the Headline Sentiment mini-project applies this exact chapter to 900 financial headlines — three-class sentiment, and the honest question of whether sentiment predicts the next day's move. Downloadable data, full solution.
⚠ Volatile content — Reviewed: October 2026 · Next review: January 2027

12. The Modern Outlook & the Road Ahead

Return to the map of Section 2. What the bag cannot see is exactly what the other regions provide:

Part VIII leaves language behind. Its data is the rawest of all: pixels and transactions. Artificial neural networks — the neuron, the activation, gradient descent, backpropagation — learn representations like the ones above. Section 3 already promised it: the same bag of 20,000 counters can be a network's input layer. The final act of the book begins with the only model here that names an industry.

🛠 Hands-On: Build, Hunt, and Beat 73%Intermediate

The operations team wants review triage. Negative reviews should be flagged automatically for a human reply. Build the pipeline. Survive the bug hunt. Then take the beat-73% challenge seriously, with the scoreboard extended.

Steps

  1. Trace re.sub('[^a-zA-Z]', ' ', "Wow... Loved this place #1!") by hand. List every character the pattern matches. Then clean "Wow... Loved this place." by hand through all five steps. Check against the first entry of the real corpus. Then clean "Crust is not good." twice: once with the stock stop-word list, once with "not" removed. Watch the bug appear.
  2. Build the toy BoW and TF-IDF matrices of Sections 6–7 by hand; verify the idf values 1.00 / 1.29 / 1.69 against the library.
  3. Reproduce the vocabulary decision. Run CountVectorizer() uncapped. Verify len(X[0]) = 1,566. Cap at 1,500. Verify the (1000, 1500) shape and the 99.6% sparsity.
  4. Run the full pipeline; verify [[55, 42], [12, 91]] and 0.730; compute precision/recall/F1 by hand from the matrix.
  5. Accept the challenge. Run logistic regression and LinearSVC on the same bag. Verify 0.775 and 0.780. Explain the precision/recall inversion.
  6. Score the three suggested fixes: (a) also remove "isn't" from the stop list; (b) bigrams via ngram_range=(1, 2); (c) MultinomialNB instead of GaussianNB. Which one beats 0.780?
  7. Swap to TF-IDF (verify 0.720) and explain why GaussianNB prefers counts.
View Solution / Walkthrough
import numpy as np, pandas as pd, re
import nltk
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer

nltk.download('stopwords', quiet=True)
sw = stopwords.words('english'); sw.remove('not')     # the bug fix
sw.remove("isn't")                                    # challenge 6a
ps = PorterStemmer()

ds = pd.read_csv('Restaurant_Reviews.tsv', delimiter='\t', quoting=3)
corpus = []
for i in range(len(ds)):
    r = re.sub('[^a-zA-Z]', ' ', ds['Review'][i]).lower().split()
    corpus.append(' '.join(ps.stem(w) for w in r if w not in set(sw)))
print(repr(corpus[0]), repr(corpus[1]))   # 'wow love place' 'crust not good'

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB, MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.metrics import (confusion_matrix, accuracy_score,
                             precision_score, recall_score, f1_score)

y = ds.iloc[:, -1].values
def run(Vec, Clf, name):
    X = Vec(max_features=1500).fit_transform(corpus).toarray()
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.2, random_state=0)
    m = Clf().fit(Xtr, ytr); yp = m.predict(Xte)
    print('%-14s acc %.3f P %.3f R %.3f F1 %.3f' % (name,
          accuracy_score(yte,yp), precision_score(yte,yp),
          recall_score(yte,yp), f1_score(yte,yp)))

print('uncapped vocabulary:', CountVectorizer().fit(corpus).transform(corpus).shape[1])
run(CountVectorizer, GaussianNB,      'NB counts')    # 0.730
run(TfidfVectorizer, GaussianNB,      'NB tfidf')     # 0.720
run(CountVectorizer, LogisticRegression, 'logistic')
run(CountVectorizer, LinearSVC,       'linSVM')       # challengers
# challenge 6: ngram bigrams + MultinomialNB — try:
# CountVectorizer(ngram_range=(1,2))  and  MultinomialNB()

Expected results (real): uncapped vocabulary 1,566 → capped (1000, 1500), 99.6% zeros. NB on counts 0.730 ([[55,42],[12,91]], R 0.883). NB on TF-IDF 0.720. Logistic 0.775 (P 0.815, R 0.728). LinearSVC 0.780 (P 0.817, R 0.738) — the precision/recall inversion of Section 8. Step 6's experiments move the score a point or two either way. Score them yourself. Bigrams attack the "not good" false alarms directly (42 → fewer). The 0.780 record stands until you beat it.

🎯 Key Takeaways

1

The map: classical NLP + deep learning = deep NLP, with seq2seq at the centre. The bag of words sits on the line. Logistic regression makes it classical. A neural network makes it deep.

2

The core idea: a real email becomes a vector of 20,000 counters. sos and eos are reserved. Unknown words go to one bucket. Six slots change. 19,994 stay zero.

3

Print your intermediate data. The stop-word list silently deleted "not". "Crust is not good" became praise. A printed corpus caught it. One line fixes it.

4

The real pipeline: 1,566 columns counted, the 66 rarest dropped (the Steves and Ricks), a 1,000 × 1,500 matrix at 99.6% zeros. TF-IDF re-weights by rarity (idf by hand: 1.00 / 1.29 / 1.69).

5

Beat-73% answered: logistic 77.5%, LinearSVC 78.0% on the same bag. They win on precision. NB keeps the recall crown (91 vs 76 fans caught). The best model depends on the business's error costs.

6

The bag cannot see order or meaning. Embeddings and transformers can — at GPU cost. For tidy reviews, the counter still ships first.

7

Three classes, two ways. Map stars to labels: on the downloadable synthetic corpus, logistic regression scores 0.978 and catches every neutral. Or abstain in a probability band: accuracy 77.5% → 93.8% as coverage falls 100% → 56.5%. The band traps no-signal reviews (88% truly neutral); mixed reviews escape it.

Test Your Understanding

32 questions from the whole chapter: the map, the email vector, the bug hunt, the matrices, the challenge scoreboard, and the three-class extension. Choose one answer per question. Then check your answers.

Q1.The dataset and task:

Q2.On the map of the field (Figure 25.2), "Deep NLP" is:

Q3.Sequence-to-sequence models sit:

Q4.The fatal weakness of if/else chatbots:

Q5.Audio-frequency speech recognition is classical NLP because:

Q6.When is the bag of words "deep NLP" rather than classical?

Q7.Why 20,000 slots for the email vector?

Q8.The reserved slots sos and eos stand for:

Q9.Roughly 3,000 words:

Q10.In Vee's email, the names Cyril, Oz and Vee all land:

Q11.Why does re.sub('[^a-zA-Z]', ' ', …) replace with a space rather than an empty string?

Q12.The bug in the cleaning pipeline, and its fix:

Q13.The main purpose of stemming "loved → love" is to:

Q14.The corpus is:

Q15.(Hand) In the toy bag of words, the row for "good good great" (features good/great/not) is:

Q16.(Real) The fitted capped matrix and its sparsity:

Q17.(Real) The 1,566 → 1,500 decision:

Q18.(Hand, n = 3 docs) A word appearing in 1 of 3 documents gets sklearn's smoothed idf:

Q19.(Hand) The "great" cell in review "good great" of the TF-IDF matrix holds:

Q20.(Code) Inside the pattern [^a-zA-Z], the caret ( ^ ) means:

Q21.(Real) GaussianNB on the raw counts scored:

Q22.Naive Bayes is text's historic classifier because:

Q23.(Real) TF-IDF with GaussianNB scored 0.720 — slightly worse. The correct explanation:

Q24.(Real) The beat-73% challenge, answered on the identical bag and split:

Q25.The 42 false alarms come from:

Q26.The one-parameter upgrade that gives "not good" its own feature:

Q27.Chapter 3's leakage rule applied to NLP:

Q28.What the bag cannot see (and transformers can):

Q29.CNN-for-text (Section 3's model #5) processes language by:

Q30.The scope decision of this chapter:

Q31.In the neutral-band method (Section 11), a review is labelled neutral when:

Q32.(Real) Widening the neutral band from 0.05 to 0.20 (logistic probabilities, 200 test reviews):