E ExamMaster

Machine Learning · Real-World ML Applications

Review Sentiment Application

TF-IDF plus a linear classifier is classical text ML — useful on short reviews before any neural language model.

Short reviews become features before any neural net. This lesson locks a binary sentiment job, why TF-IDF is a strong classical baseline on tiny text, and a sklearn pipeline you can run in the browser.

  • Machine Learning
  • Medium level
  • 6 concepts
  • 10 practice questions

1Short text labels

Every app store and shop page collects reviews faster than any team can read them. Sentiment analysis is the job of deciding, from the text alone, whether the writer sounds happy or unhappy — so that support and product teams can triage: route each incoming review into a positive queue or a negative queue, and read the angry ones first. That is the whole decision this lesson builds a model for: one short piece of text arrives, and it lands in one of two buckets. In machine learning terms this is binary classification — 'binary' because there are exactly two possible answers, 'classification' because the answer is a category rather than a number.

Before any model, the label needs a policy. The label is the answer column: for each review in your training file, a 1 if it is positive and a 0 if it is negative. If your reviews come with star ratings, a common policy is stars of 4 or 5 mean positive and 1 or 2 mean negative — but notice that leaves 3-star reviews undecided, and 'stars ≤ 2' is genuinely not the same question as 'mentions a defect' (a 5-star review can still report a broken hinge). Whoever builds the file must write the policy down and apply it consistently, because the model can only learn the question the labels actually answer. One more rule: the model may only read text that exists at prediction time. A support agent's resolution notes are written after the ticket closes, so gluing them onto the review text would train a model on information it will never have when a fresh review arrives.

To make every step of this lesson checkable by hand, we will carry three tiny reviews through the whole topic and do all the arithmetic on them. R1: "battery dies fast" — labelled negative. R2: "great battery life" — labelled positive. R3: "fast shipping" — labelled positive. Three reviews, three words or fewer each. They are small enough that when we count words, weight them, and score them, you can verify every number yourself — and that is exactly what the next concepts do.

The framing in words: a short review arrives, the model reads only its text, and the review lands in the positive or the negative queue — so humans read the angry ones first.

Sentiment framing
Lock thisIn this lesson
DecisionRoute review to positive or negative queue
Label1 = positive, 0 = negative (write the star policy down)
InputsReview text only — nothing written after the review
Running exampleR1 "battery dies fast" (neg), R2 "great battery life" (pos), R3 "fast shipping" (pos)
You have 800 labelled reviews. Why start with bag-of-words rather than a neural language model?
  1. Neural models cannot classify text shorter than a paragraph
  2. 800 rows cannot support a large model, and a sparse linear baseline sets the number anything fancier has to beat
  3. Bag-of-words is more accurate than embeddings on any dataset
  4. Sentiment is a regression problem, which neural nets cannot handle

The baseline is not a consolation prize, it is the measurement. Without it you cannot tell whether a heavier model earned its complexity or merely arrived with it.

2Bag-of-words: text becomes counts

A model like the ones in this course fits numbers — it cannot fit a line through the word "battery". So the first real step is featurisation: turning each review into a row of numbers. The oldest and still remarkably strong recipe is called bag-of-words, and the name is literal: imagine tipping all the words of a review into a bag, so their order is lost, and then just counting how many times each word landed in the bag. Before counting, the text is tokenised — split into words, lowercased, punctuation stripped — so that "Great!" and "great" become the same token. A token is simply a cleaned-up word.

Counting needs a fixed list of slots, and that list is the vocabulary: every distinct token that appears anywhere in your training reviews, in a fixed (say alphabetical) order. Across our three reviews the vocabulary has exactly six words: battery, dies, fast, great, life, shipping. Each review then becomes a count vector — one slot per vocabulary word, holding how many times that word occurs in that review. Read R2 "great battery life" against the six slots: battery appears once (1), dies not at all (0), fast not at all (0), great once (1), life once (1), shipping not at all (0). So R2 becomes the vector (1, 0, 0, 1, 1, 0). Every review in the file gets its own such row, and the table below shows all three.

Notice how many zeros there are: even in this toy corpus, half of R2's slots are empty. On a real corpus the vocabulary has hundreds or thousands of words, while any single review uses only a handful — so almost every slot in every row is zero. A vector that is mostly zeros is called sparse, and libraries store only the non-zero entries, which is why a 10,000-word vocabulary does not blow up memory. The worked example below does this arithmetic.

The figure is the count table below: one row per vocabulary word, one column per review, each cell the number of times that word occurs in that review — R1 and R2 share the battery row, and only R1 has a count in the dies row.

Term counts for the three running reviews
WordR1 "battery dies fast"R2 "great battery life"R3 "fast shipping"
battery110
dies100
fast101
great010
life010
shipping001

Three reviews become a matrix of counts

Build the vocabulary for R1 "battery dies fast", R2 "great battery life", R3 "fast shipping", write R1's count vector, and measure how sparse it is — here and on a realistic 200-word vocabulary.

  • Distinct tokens across R1, R2, R3, sorted: battery, dies, fast, great, life, shippingvocabulary of 6
  • R1 "battery dies fast" counted against those 6 slots(1, 1, 1, 0, 0, 0)
  • Non-zero slots in R1's vector: 3 of 650% zeros
  • Same 3-word review against a 200-word vocabulary: zeros = 197 of 20098.5% zeros

Pro tip. That 98.5% is why the word 'sparse' keeps appearing: real text vectors are almost entirely zeros, and everything downstream — storage, and the linear model later in this lesson — is built to exploit that.

Two reviews use exactly the same words in a different order: "great screen, cracked case" and "cracked screen, great case". What does bag-of-words produce for them?
  1. Two different vectors, because word order changes the counts
  2. An error, because a word may not appear in two reviews
  3. Identical vectors — order went into the bag and never came out
  4. Identical vectors only if the reviews have the same label

Bag-of-words keeps counts and discards order, so any two texts with the same words at the same counts become the same vector. That lost order is the price of the recipe — and the reason phrase features (bigrams) exist.

3TF-IDF

Raw counts treat every word as equally informative, and they are not. In R1 "battery dies fast", the words battery and dies both count 1 — but battery also appears in the glowing R2, so it signals the topic (this review is about the battery), not the mood. Dies appears in the negative review and nowhere else: that is the sentiment carrier. We want each word's number to reflect not just how often it occurs, but how distinctive it is.

TF-IDF builds that number from two ingredients. Term frequency, tf, is how often the word occurs in this one review — in our three-word reviews every tf is 1. Document frequency, df, is how many reviews in the corpus contain the word at all: df(battery) = 2 (it is in R1 and R2), df(dies) = 1. Inverse document frequency turns df into a rarity score, \mathrm{idf}(t) = \ln\frac{N}{\mathrm{df}(t)}, where N is the number of reviews (here 3) and \ln is the natural logarithm. Read its behaviour at the extremes: a word in every review gets \ln(3/3) = \ln 1 = 0 — a word present everywhere separates nothing, so it is worth zero — while a word in just one review gets the largest value, \ln(3/1) \approx 1.10.

The full weight is the product, \text{tf-idf}(t, d) = \text{tf}(t, d) \times \mathrm{idf}(t): high when a word is frequent in this review and rare across the corpus. The worked example computes it for battery and dies; fast, which like battery appears in two of the three reviews, gets the same weight as battery, 1 \times \ln(3/2) \approx 0.41. One practical note: sklearn's TfidfVectorizer uses a slightly smoothed idf formula and then rescales each row to unit length, so its numbers differ a little from this hand version — but the story is identical: distinctive words up, everywhere-words down.

Figure. TF-IDF weights for the three words of R1 "battery dies fast", computed by hand in the worked example: battery and fast each appear in two of the three reviews, so idf = ln(3/2) gives them 0.41; dies appears only in the negative review, so idf = ln(3/1) lifts it to 1.10. The sentiment carrier now towers over the topic words.

TF-IDF for R1's three words
WordAppears inidf = \ln(3/\text{df})tf-idf in R1
batteryR1, R2 — df 2\ln(3/2) = 0.410.41 — topic word
diesR1 only — df 1\ln(3/1) = 1.101.10 — the sentiment carrier
fastR1, R3 — df 2\ln(3/2) = 0.410.41

TF-IDF by hand: 'battery' vs 'dies' in R1

Corpus: R1 "battery dies fast", R2 "great battery life", R3 "fast shipping", so N = 3. Compute the TF-IDF weight of battery and of dies inside R1, using idf(t) = ln(N/df(t)).

  • df(battery): appears in R1 and R22
  • idf(battery) = ln(3/2)0.41
  • df(dies): appears in R1 only1
  • idf(dies) = ln(3/1)1.10
  • tf-idf in R1: battery 1 × 0.41, dies 1 × 1.100.41 vs 1.10
  • Weight ratio, dies against battery: 1.10 / 0.41≈ 2.7×

Pro tip. Push the idf logic to its limit: a word in all three reviews would score ln(3/3) = 0 — filler erases itself. That is the whole trick, and it needs no list of stop words to do it.

'The' turns up in almost every review. What does the IDF half of TF-IDF do to it?
  1. It raises the weight, since frequent tokens are the most reliable
  2. It deletes the token, because IDF drops anything above a fixed frequency
  3. It leaves the weight alone, since IDF only touches rare tokens
  4. It pushes the weight down, because a token present everywhere separates nothing

Inverse document frequency scores how discriminating a token is. Appearing in every document makes a word useless for telling documents apart, whatever its raw count.

4A linear classifier on word weights

Now the review is a sparse vector of TF-IDF weights, and a linear classifier — LogisticRegression in sklearn — turns it into a verdict. What it learns during training is disarmingly simple: one number per vocabulary word, called that word's weight (or coefficient). A positive weight means the word's presence is evidence the review is positive; a negative weight means evidence it is negative. To score a review, the model multiplies each present word's TF-IDF value by that word's learned weight and adds everything up. Words absent from the review contribute exactly zero — which is why sparsity is a gift: only the handful of non-zero slots do any work.

The sum, usually written z, can come out as any number — 3.2, or −1.86. To turn it into something a triage queue can use, the logistic function squashes it into a probability between 0 and 1: p = \frac{1}{1 + e^{-z}}, read as the model's probability that the review is positive. A large positive z gives p near 1, a large negative z gives p near 0, and z = 0 gives exactly 0.5 — the fence. The standard rule then routes: p \ge 0.5 to the positive queue, below it to the negative queue.

Suppose training on a big pile of labelled reviews landed on these (illustrative, but realistically shaped) weights for R1's three words: dies −1.8, battery −0.1, fast +0.4 — a strongly negative word, a near-neutral topic word, a mildly positive word. The worked example scores R1 with them, and the payoff generalises: after fitting any text classifier, print the most negative and most positive weights and read them. If the top negative words look angry and the top positive ones look delighted, the model has learned something a human recognises; if garbage tokens carry big weights, you have found a data problem before it found you.

Figure. R1 'battery dies fast' scores by weight × TF-IDF: battery −0.1 × 0.41 = −0.04, dies −1.8 × 1.10 = −1.98, fast +0.4 × 0.41 = +0.16. Sum z = −1.86. Then p = 1/(1+e^{1.86}) ≈ 0.13, which is below 0.5, so the review routes to the negative queue. One strong word outvoted two milder ones. Boxes are equal chips, not to scale.

  1. One weight per wordTraining assigns every vocabulary word a coefficient: positive words push toward the positive queue, negative words the other way.
  2. Multiply and addFor the words the review contains, sum weight × TF-IDF. Absent words contribute zero — sparsity keeps this cheap.
  3. Squash and routeThe logistic function turns the sum into p(positive); at or above 0.5 the review routes positive, below it negative.

Scoring 'battery dies fast' by hand

R1's TF-IDF values are battery 0.41, dies 1.10, fast 0.41 (from the TF-IDF worked example). The trained weights are battery −0.1, dies −1.8, fast +0.4. Compute the score z, the probability the review is positive, and the queue it lands in.

  • battery: weight −0.1 × tf-idf 0.41−0.04
  • dies: weight −1.8 × tf-idf 1.10−1.98
  • fast: weight +0.4 × tf-idf 0.41+0.16
  • z = −0.04 − 1.98 + 0.16−1.86
  • p(positive) = 1 / (1 + e^{1.86})≈ 0.13
  • 0.13 < 0.5negative queue

Pro tip. One strongly weighted word (dies at −1.98) outvoted two milder ones put together. That is normal for sentiment — and it is exactly why reading the top coefficients after training is the cheapest sanity check you will ever run.

A trained model gives 'refund' weight −2.1 and 'love' weight +1.9. A new review contains both, with equal TF-IDF values. What does the model do?
  1. Flags it positive, because 'love' is the emotionally stronger word
  2. Ignores both words, because their weights cancel exactly
  3. Adds both contributions; at equal TF-IDF the sum tilts slightly negative
  4. Refuses to score a review containing contradictory sentiment

A linear model has no notion of which word 'feels' stronger — only the arithmetic of weight × value, summed. With equal TF-IDF, −2.1 + 1.9 = −0.2: slightly negative, and the review routes accordingly.

5Fit the vectoriser on train only

Here is the step where careful people quietly cheat. A TfidfVectorizer is fitted, exactly like a model: from the data it is shown, it learns the vocabulary (which words get slots) and every idf value (how rare each word is). So it matters enormously which data it is shown. If you vectorise the whole file first and split into train and test afterwards, the test reviews have already shaped the features — their words are in the vocabulary, their document counts are inside every idf. The held-out score then no longer measures performance on unseen data, because the test rows helped build the machine that reads them. That is leakage, and it always errs in the flattering direction.

The fix is to chain the vectoriser and the classifier into a single Pipeline object — in sklearn, make_pipeline(TfidfVectorizer(...), LogisticRegression(...)). Now one call to fit(X_train, y_train) fits both stages on training rows only, and one call to score(X_test, y_test) transforms the test reviews using the train-built vocabulary and idf before scoring them. A test-set word that never occurred in training simply gets no slot — which sounds like a flaw and is actually the honest behaviour, because tomorrow's real reviews will also contain words today's model has no weight for. One more knob belongs here: max_features=200 tells the vectoriser to keep only the 200 most frequent words, which caps the matrix size — in a browser teaching runtime that cap is what keeps the lab snappy, and on real corpora it drops one-off typos that would otherwise each claim a column.

Figure. The leakage boundary: after the split, everything the pipeline learns — vocabulary, idf values, classifier weights — comes from the 90 training rows alone. The 30 test rows cross to the right exactly once, transformed by the train-built features and scored. Vectorise before splitting and the test rows would sit inside the left box too.

  1. Split firstCut train and test before anything is fitted — the vectoriser counts as something that is fitted.
  2. Fit inside the pipelinemake_pipeline(vectoriser, classifier); fit(X_train, y_train) builds vocabulary, idf and weights from training rows only.
  3. Score the held-out rowsscore(X_test, y_test) transforms test text with the train-built features and reports accuracy on rows nothing was fitted on.
The lab wraps the vectoriser and the classifier in one pipeline. Why not vectorise the whole CSV first and split afterwards?
  1. TfidfVectorizer is unable to accept a pandas column
  2. The vocabulary and the IDF weights would then be built partly from the test rows, which is leakage
  3. A pipeline runs faster than the same two steps written separately
  4. max_features only takes effect inside a pipeline

A vectoriser is fitted, exactly like a model. Fit it before the split and the test rows have already influenced the features, so the reported score is no longer about unseen data.

6Lab: sentiment baseline

Time to run the whole lesson as sixteen lines of code. The dataset is /data/review_sentiment_v1.csv, a synthetic teaching file of 120 rows and two columns: text, a short review phrase, and label, a 1 for positive and a 0 for negative. It is exactly balanced — 60 positive rows and 60 negative — and the phrases are short, around five or six words each, in the same spirit as our three running reviews: "terrible quality broke quickly" sits next to "loved the quality highly recommend". Because the file is generated for teaching, it is deliberately clean: every positive row carries clearly positive words and every negative row clearly negative ones, with no sarcasm, no typos, and no mixed feelings. Keep that in mind for when the score prints — it explains what you will see.

Walk the code before running it. pd.read_csv loads the file into a table called df, and train_test_split hands the text column and the label column in together, so every review stays paired with its own label through the shuffle. The four names it returns follow the convention used all over scikit-learn: the capital X marks inputs (here, review text), the small y marks answers (the 0/1 labels), and the suffix says which pile the piece belongs to — X_train and y_train are the rows the model will study, X_test and y_test the rows locked away for the honest measurement. test_size=0.25 holds out a quarter of the rows, so the piles come out at 90 training reviews and 30 test reviews — the first print line shows exactly those two numbers, and checking them is worth the two seconds, because a mistyped fraction here quietly changes every number after it. random_state=0 pins the shuffle so every run (yours today, yours tomorrow, a classmate's) cuts the identical 30-row test set; with this seed, 43 of the 90 training rows are positive and 17 of the 30 test rows are — close to the file's 50/50 balance, as a random cut should be.

The model is the pipeline from the leakage concept, verbatim: make_pipeline(TfidfVectorizer(max_features=200), LogisticRegression(max_iter=500)). The vectoriser learns the vocabulary and idf values from the 90 training reviews only — on this small file that vocabulary comes out at just 90 distinct words, so the 200-word cap never actually bites here; it is in the code because on any real corpus it must be. max_iter=500 simply gives the optimiser inside LogisticRegression enough iterations to converge cleanly — fitting a logistic model is an iterative search for the best weights, and the default iteration budget sometimes runs out with a convergence warning. model.fit(X_train, y_train) does all the learning in one call, and model.score(X_test, y_test) reports accuracy: the fraction of the 30 held-out reviews routed into the correct queue. Accuracy always deserves a floor to stand on: a lazy 'model' that ignores the text and guesses positive every time would get the 17 positive test rows right and the 13 negative ones wrong, scoring 17/30 ≈ 0.57 — so anything the real model earns must be read against that, not against zero.

Run it, and the score prints 1.000 — all 30 test reviews correct. Resist the celebration: this does not mean sentiment analysis is solved; it means the plumbing works and the file is easy. A templated dataset whose positive rows all contain words like "great" and "amazing" is exactly the kind of corpus a TF-IDF baseline aces; real reviews — with sarcasm, negation like "not good", and misspellings — land such baselines in the 80s and low 90s, not at 100. A more instructive experiment than admiring the 1.000 is to starve the model: change max_features to 20 and the score drops to 0.867; at 10 it is 0.733; at 5 it is 0.467 — no better than flipping a coin. With only five word-slots to see through, most reviews contain none of the surviving words, vectorise to all zeros, and become indistinguishable. That collapse, reproduced in two keystrokes, is the whole argument for giving a text model enough vocabulary to work with.

The last six lines of the starter open the model up instead of just scoring it. model.named_steps lets you reach the fitted pieces inside the pipeline by name — sklearn names each step after its class, lowercased, so "tfidfvectorizer" and "logisticregression" fetch the two stages. From the vectoriser, get_feature_names_out() returns the learned vocabulary in slot order — the fixed word list the whole matrix is counted against, exactly as in the bag-of-words concept, except now it was learned from the training rows rather than written by hand. From the classifier, clf.coef_[0] holds the learned weight for each slot: the same one-number-per-word coefficients you hand-computed with in the linear-classifier concept, now 90 of them, learned from data (the [0] is there because sklearn stores the weights as a matrix with one row per class boundary, and a binary problem has exactly one row). Zipping weights with words and sorting the pairs puts the most negative words first and the most positive last, so printing both ends shows the model's mind: expect broke, quickly, terrible and disappointed among the most negative, and value, amazing and recommend among the most positive — angry words negative, delighted words positive, which is the sanity check passing.

You will also spot something instructive in both lists: filler like "of", "for" and "the" picks up real weight. On 120 templated rows, a filler word that happens to sit inside one class's stock phrasing looks discriminating — "waste of money" puts "of" in negative company often enough for the model to lean on it — and idf, which only zeroes words present in nearly every document, cannot fully police a corpus this small. On production corpora you would counter with min_df (ignore words that appear in too few documents), a stop-word list, or simply more data; and if negation like "not good" is the failure you see, ngram_range=(1, 2) on the vectoriser adds two-word tokens so the phrase gets its own slot and its own weight. Every one of those fixes starts the same way: you printed the coefficients and read them. That habit — score first, then look inside — is the transferable skill this lab is actually teaching, and it survives long after TF-IDF itself has been replaced by whatever your next course reaches for.

What a successful run prints, in words: first the split sizes (90 train, 30 test), then score 1.000, then the five most negative words led by broke and terrible, then the five most positive led by value and amazing.

Lab checklist
StepWhy
Check the split prints 90 train / 30 testConfirms the seeded quarter hold-out before any fit
Expect score 1.000 on this fileThe synthetic corpus is deliberately easy — read it as 'plumbing works', not 'problem solved'
Re-run with max_features 20, 10, 5Watch 0.867 → 0.733 → 0.467: a starved vocabulary collapses to coin-flipping
Read both coefficient listsAngry words should be negative, delighted words positive — and spotting filler words with weight is the lesson

Coding lab. Sentiment TF-IDF runs in the app, with checks on your output.

The lab prints score 1.000. What is the right conclusion?
  1. Sentiment analysis is solved; ship this model on real reviews
  2. The pipeline works and this synthetic teaching file is deliberately easy — expect real, messier reviews to score lower
  3. The model overfitted, so the score must be discarded
  4. The vectoriser leaked test rows into the vocabulary

The score is honest — the pipeline fitted on train only and the 30 test rows were unseen. But a templated corpus where every positive row carries obvious positive words is the easiest possible exam. A perfect score here verifies the plumbing; sarcasm, negation and typos in real reviews are what lower it.

Notes

  • Classify short reviews with TF-IDF + linear model.
  • Support and product teams triage reviews into positive and negative buckets so humans read the angry ones first.
  • Term frequency counts how often a token appears in a review; inverse document frequency downweights tokens that appear in almost every review ("the", "product").

Exam traps & shortcuts

  • Keep lab datasets under 2000 rows in the browser runtime.
  • Split train and test before fitting any model that sees labels.

Recap

This lesson in brief:

Short text labels
Support and product teams triage reviews into positive and negative buckets so humans read the angry ones first.
TF-IDF
Term frequency counts how often a token appears in a review; inverse document frequency downweights tokens that appear in almost every review ("the", "product").
Lab: sentiment baseline
Vectorise review_sentiment_v1 with TfidfVectorizer, fit LogisticRegression in a pipeline, and print hold-out accuracy.

Practise Review Sentiment Application

Reading is free and needs no account. Practice, mocks and progress live in the app.

  • 10 exam-style questions on this topic, with explanations
  • A 5-question practice set that ends the chapter
  • Timed mocks scored with the real marking scheme
  • Readiness tracked per topic, kept on your device
Continue with Google — freeNo card, no trial. Works offline once installed.