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.
🎯 Learning Objectives
- Draw the map of NLP: classical NLP, deep learning, their overlap (deep NLP), and sequence-to-sequence at the centre. Name one real model from each region
- Explain the core idea: a real email as a vector of 20,000 counters. Each word has a fixed slot. Special slots are kept for sos, eos and unknown words
- Explain why the same vector is classical NLP with logistic regression, and deep NLP inside a neural network
- Clean text by hand on a real review. Read the re.sub pattern piece by piece. Find the bug where the stop-word list deleted "not"
- Build a bag-of-words count matrix by hand. Reproduce the real 1,566 → 1,500 vocabulary decision. Read a 99.6%-sparse matrix
- Compute TF-IDF by hand. Check your numbers against the real library output
- Classify sentiment with Naive Bayes (73.0%). Read its confusion matrix. Then answer the beat-73% challenge with real rival scores (77.5%, 78.0%)
- Build a three-class system: positive, neutral, negative — from three-label data (star mapping) or with a neutral probability band. Read the coverage–accuracy trade-off
- State clearly what bag of words cannot see, and name the models that can
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:
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 (left circle): any model that works with human language. No neural network is needed.
- Deep learning (right circle): any neural network, applied to anything: images, numbers, language.
- Deep NLP (the overlap): the intersection of the two. Neural networks that process language. Embeddings, LSTMs and transformers live here.
- Sequence-to-sequence (innermost): the centre of deep NLP. These models read a sequence and write one. Translation engines, chatbots and caption generators.
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.
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.
| Model | Region | How it works | Fate |
|---|---|---|---|
| 1 · If/else chatbots | classical NLP | a hand-written list of expected questions. Each question has a fixed answer. Match the input, return the script | the chatbots of the 2000s. Brittle: one unexpected phrasing breaks them |
| 2 · Audio-frequency speech recognition | classical NLP | split the sound wave into its frequencies (Fourier-style maths). Match the pattern against a prepared library. No neurons anywhere | replaced by deep models. But a perfect example of NLP without neural networks |
| 3 · Bag of words → classical classifier | classical NLP | count words into a vector. Classify with logistic regression or Naive Bayes — this chapter's build | still the professional baseline for sentiment (Section 10) |
| 4 · Bag of words → neural network | deep NLP | the same vector, used as an input layer of 20,000 neurons in Chapter 26's network | the bridge: same representation, deeper model |
| 5 · CNN for text / seq2seq | deep NLP → seq2seq | words 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 another | the 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:
| Comment | Mark |
|---|---|
| "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.
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):
| Number | What it counts |
|---|---|
| 171,476 | entries in the Oxford dictionary (with obsolete and derivative forms) — the language's full inventory |
| 20,000–35,000 | words known by an average adult native speaker |
| ≈10,000 / ≈5,000 | words known by an average native speaker at age 8 / at age 4 — vocabulary grows with age |
| ≈3,000 | words that cover about 95% of common text. Just 1.75% of the dictionary does 95% of the work |
| 20,000 | the 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
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:
the same vectors as a 20,000-neuron input layer = deep NLP
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.
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:
| Step | Operation | Result |
|---|---|---|
| 1 · Scrub | re.sub('[^a-zA-Z]', ' ', …) | Wow Loved this place |
| 2 · Lowercase | .lower() | wow loved this place |
| 3 · Tokenise | .split() | [wow, loved, this, place] |
| 4 · Stopwords | drop the filler list (this…) | [wow, loved, place] |
| 5 · Stem | loved → 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:
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:
| Symbol | Name | Meaning |
|---|---|---|
[ ] | character set | matches 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-z | range | all small letters, a to z |
A-Z | range | all 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 text | Characters the pattern finds | Output 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".
[ ], 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:
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 | |
|---|---|---|
| Method | Chops suffixes by rules | Dictionary + grammar lookup |
| loved → | love | love |
| studies → | studi (not a word) | study |
| better → | better | good |
| Why it matters | love and loved would otherwise be two redundant columns of the sparse matrix | same 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:
| Review | good | great | not |
|---|---|---|---|
| "good great" | 1 | 1 | 0 |
| "not good" | 1 | 0 | 1 |
| "good good great" | 2 | 1 | 0 |
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
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.
| Quantity | Value (real) | Reading |
|---|---|---|
| Matrix shape | 1,000 × 1,500 | One row per review. One column per kept word |
| Sparsity | 99.6% zeros | Each review uses about 6 of the 1,500 words. Section 4's email vector, shrunk to toy scale |
| Sample features | absolut, acknowledg, actual, … | Stemmed forms. The vocabulary is the word list of the stemmed corpus |
.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:
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:
| Review | good (idf 1.00) | great (idf 1.29) | not (idf 1.69) |
|---|---|---|---|
| "good great" | 1.00 | 1.29 | 0 |
| "not good" | 1.00 | 0 | 1.69 |
| "good good great" | 2.00 | 1.29 | 0 |
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.
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:
| Model | Confusion matrix | Accuracy | Precision | Recall | F1 |
|---|---|---|---|---|---|
| NB on raw counts | [[55, 42], [12, 91]] | 0.730 | 0.684 | 0.883 | 0.771 |
| NB on TF-IDF | [[57, 40], [16, 87]] | 0.720 | 0.685 | 0.842 | 0.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 matrix | Accuracy | Precision | Recall | F1 |
|---|---|---|---|---|---|
| Naive Bayes (the 73% to beat) | [[55, 42], [12, 91]] | 0.730 | 0.684 | 0.883 | 0.771 |
| Logistic regression (Ch 11) | [[80, 17], [28, 75]] | 0.775 | 0.815 | 0.728 | 0.769 |
| Linear SVM (Ch 13) | [[80, 17], [27, 76]] | 0.780 | 0.817 | 0.738 | 0.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.
ngram_range=(1, 2)), try MultinomialNB. The
hands-on below scores them.
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.
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.
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 | ||
|---|---|---|
| Actual | Not liked | Liked |
| Not liked (97) | 55 TN — correctly ignored | 42 FP — false praise |
| Liked (103) | 12 FN — missed fans | 91 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.
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:
# ---- 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) | ||||
|---|---|---|---|---|---|---|
| neg | neu | pos | neg | neu | pos | |
| Negative (58) | 57 | 0 | 1 | 58 | 0 | 0 |
| Neutral (58) | 16 | 24 | 18 | 0 | 58 | 0 |
| Positive (64) | 2 | 0 | 62 | 4 | 0 | 60 |
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:
Which model's probabilities can we trust? Test both, on the same 200 test reviews:
| Model | Median probability | Probabilities outside 0.1–0.9 | Reviews inside the 0.4–0.6 band |
|---|---|---|---|
| GaussianNB | 1.0000 | 100% | 0 |
| Logistic regression | 0.46 | 14% | 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):
| Negative | Neutral | Positive | Answered | Accuracy on answered |
|---|---|---|---|---|
| 81 | 49 | 70 | 151 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) | Thresholds | Neg | Neutral | Pos | Coverage | Accuracy on answered |
|---|---|---|---|---|---|---|
| 0.00 | 0.50 / 0.50 | 108 | 0 | 92 | 100% | 0.775 |
| 0.05 | 0.45 / 0.55 | 97 | 17 | 86 | 91.5% | 0.809 |
| 0.10 | 0.40 / 0.60 | 81 | 49 | 70 | 75.5% | 0.834 |
| 0.15 | 0.35 / 0.65 | 68 | 70 | 62 | 65.0% | 0.877 |
| 0.20 | 0.30 / 0.70 | 60 | 87 | 53 | 56.5% | 0.938 |
| 0.30 | 0.20 / 0.80 | 39 | 123 | 38 | 38.5% | 0.961 |
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):
| p | Review | True 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 band | Share of the band | Share of the class caught | |
|---|---|---|---|
| Truly negative | 1 | 2.0% | 1 of 58 (1.7%) |
| Truly neutral | 45 | 88.2% | 45 of 58 (77.6%) |
| Truly positive | 5 | 9.8% | 5 of 64 (7.8%) |
| Total | 51 | 100% | 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.
# ---- 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.
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:
- What the bag cannot see: order, context and meaning. "Not good" looks like "good". "food was good, service was a disaster" counts both sides equally. Classical patches — bigrams, negation handling — treat the symptoms, not the cause.
- Word embeddings (word2vec, GloVe, ~2013): each word becomes a dense vector. Similar meanings sit close together. "good" and "great" become neighbours. "not" becomes a meaningful direction. This is the door from the left circle into the overlap. Section 3's CNN-for-text builds on these matrices. Chapter 26's machinery learns them.
- Transformers (2017→): attention-based networks read each word in context. "bank" in "river bank" and "bank" in "bank transfer" get different vectors. Pretrained models (BERT and its descendants) turned NLP into a fine-tuning discipline. The seq2seq corner grew the chatbots and translators of the innermost ring.
- The humble counterpoint: on short, tidy reviews, this chapter's 78% is close to a transformer's low-90s. But the transformer needs GPUs, adds latency, and is harder to audit. Chapter 22's lesson applies again: the question picks the tool.
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
- 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. - 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.
- Reproduce the vocabulary decision. Run
CountVectorizer()uncapped. Verifylen(X[0])= 1,566. Cap at 1,500. Verify the (1000, 1500) shape and the 99.6% sparsity. - Run the full pipeline; verify [[55, 42], [12, 91]] and 0.730; compute precision/recall/F1 by hand from the matrix.
- Accept the challenge. Run logistic regression and LinearSVC on the same bag. Verify 0.775 and 0.780. Explain the precision/recall inversion.
- 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? - 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
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.
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.
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.
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).
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.
The bag cannot see order or meaning. Embeddings and transformers can — at GPU cost. For tidy reviews, the counter still ships first.
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):