TAI BUI
Back to curriculum
BuildPython

Sentiment Analysis

The canonical NLP task. Every failure mode you find here is the reason some richer model got invented downstream.

TIME: ~75 minutesPREREQUISITES: Phase 5 · 02 (BoW + TF-IDF), Phase 2 · 14 (Naive Bayes)

Learning Objectives

  • Build a Naive Bayes classifier from scratch and explain why its 'naive' independence assumption still produces strong results on sparse text features.
  • Implement negation scoping with the `NOT_` prefix trick and explain why it fixes bag-of-words sentiment classifiers' biggest failure mode.
  • Choose the right evaluation metric for imbalanced sentiment data (macro-F1, AUROC/AUPRC, confusion matrix) instead of defaulting to accuracy.
  • Decide when a classical TF-IDF plus logistic regression baseline is enough, and when the task needs a transformer instead.

The Problem

"The food was not great." Positive or negative?

Sentiment sounds simple. A reviewer said they liked or did not like something. Label the sentence. The reason it became the canonical NLP task is that every easy-looking case hides a hard one. Negation flips meaning. Sarcasm inverts it. "Not bad at all" is positive despite two negative-coded words. Emojis carry more signal than surrounding text. Domain vocabulary matters too: tight in a music review means something different than tight in a fashion review.

Sentiment is a working lab for classical NLP. If you understand why every naive baseline has a specific failure mode, you understand why every richer model was invented. This lesson builds a Naive Bayes baseline from scratch, adds logistic regression, and names the traps that make production sentiment a compliance-grade problem.

Warm-up0/2
1. A bag-of-words vector for 'dog bites man' is identical to the one for 'man bites dog'. What does that show about bag-of-words?Warm-up
2. A model fits its training data almost perfectly but does much worse on new data. What is this called?Warm-up
Check: The Problem0/2
1. In sentiment analysis, what is polarity?Define
2. Why is 'The food was not great' a hard case for a bag-of-words classifier?Explain

The Concept

Classical sentiment is a two-step recipe.

  1. Represent. Turn the text into a feature vector. BoW, TF-IDF, or n-grams.
  2. Classify. Fit a linear model (Naive Bayes, logistic regression, SVM) on labeled examples.

Naive Bayes is the dumbest model that works. Assume every feature is independent given the label. Estimate P(word | positive) and P(word | negative) from counts. At inference, multiply the probabilities. The "naive" independence assumption is laughably wrong and yet the results are shockingly strong. The reason: with sparse text features and moderate data, the classifier cares about which side each word leans toward more than how much.

Logistic regression fixes the independence assumption. It learns a weight per feature, including negative weights. not good as a bigram feature gets a negative weight. Naive Bayes cannot do that for bigrams it has never labeled.

Check: The Concept0/4
1. What two steps make up classical sentiment analysis?Define
2. What is the Naive Bayes classifier in this lesson?Define
3. Why does Naive Bayes work despite its 'naive' independence assumption?Explain
4. How does logistic regression fix the independence assumption problem that Naive Bayes has?Explain

Build It

Step 1: a real mini-dataset

python
POSITIVE = [
    "absolutely loved this movie",
    "beautiful cinematography and a great story",
    "one of the best films of the year",
    "brilliant acting from the lead",
    "heartwarming and funny",
]
 
NEGATIVE = [
    "boring and far too long",
    "not worth your time",
    "the plot made no sense",
    "terrible acting, awful script",
    "i want my two hours back",
]

Small on purpose. Real work uses tens of thousands of examples (IMDb, SST-2, Yelp polarity). The math is identical.

Step 2: multinomial Naive Bayes from scratch

python
import math
from collections import Counter
 
 
def train_nb(docs_by_class, vocab, alpha=1.0):
    class_priors = {}
    class_word_probs = {}
    total_docs = sum(len(d) for d in docs_by_class.values())
 
    for cls, docs in docs_by_class.items():
        class_priors[cls] = len(docs) / total_docs
        counts = Counter()
        for doc in docs:
            for token in doc:
                counts[token] += 1
        total = sum(counts.values()) + alpha * len(vocab)
        class_word_probs[cls] = {
            w: (counts[w] + alpha) / total for w in vocab
        }
    return class_priors, class_word_probs
 
 
def predict_nb(doc, class_priors, class_word_probs):
    scores = {}
    for cls in class_priors:
        s = math.log(class_priors[cls])
        for token in doc:
            if token in class_word_probs[cls]:
                s += math.log(class_word_probs[cls][token])
        scores[cls] = s
    return max(scores, key=scores.get)

Additive smoothing (alpha=1.0) is Laplace smoothing. Without it, a word unseen in a class has probability zero and the log explodes. alpha=0.01 is common in practice. alpha=1.0 is the teaching default.

Step 3: logistic regression from scratch

python
import numpy as np
 
 
def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20)))
 
 
def train_lr(X, y, epochs=500, lr=0.05, l2=0.01):
    n_features = X.shape[1]
    w = np.zeros(n_features)
    b = 0.0
    for _ in range(epochs):
        logits = X @ w + b
        preds = sigmoid(logits)
        err = preds - y
        grad_w = X.T @ err / len(y) + l2 * w
        grad_b = err.mean()
        w -= lr * grad_w
        b -= lr * grad_b
    return w, b
 
 
def predict_lr(X, w, b):
    return (sigmoid(X @ w + b) >= 0.5).astype(int)

L2 regularization matters here. Text features are sparse; without L2 the model memorizes training examples. Start at 0.01 and tune.

Step 4: handling negation (the failure mode)

Consider "not good" and "not bad". A BoW classifier sees {not, good} and {not, bad} and learns from whichever showed up more in training. A bigram classifier sees not_good and not_bad and learns them as distinct features. That is usually enough.

A cruder fix that works when you do not have bigrams: negation scoping. Prefix tokens following a negation word with NOT_ up to the next punctuation.

python
NEGATION_WORDS = {"not", "no", "never", "nor", "none", "nothing", "neither"}
NEGATION_TERMINATORS = {".", "!", "?", ",", ";"}
 
 
def apply_negation(tokens):
    out = []
    negate = False
    for token in tokens:
        if token in NEGATION_TERMINATORS:
            negate = False
            out.append(token)
            continue
        if token in NEGATION_WORDS:
            negate = True
            out.append(token)
            continue
        out.append(f"NOT_{token}" if negate else token)
    return out
python
>>> apply_negation(["not", "good", "at", "all", ".", "but", "funny"])
['not', 'NOT_good', 'NOT_at', 'NOT_all', '.', 'but', 'funny']

Now good and NOT_good are different features. The classifier can weight them opposite. Three lines of preprocessing, measurable accuracy jump on sentiment benchmarks.

Step 5: evaluation metrics that matter

Accuracy alone is misleading if classes are imbalanced. Real sentiment corpora are usually 70-80% positive or 70-80% negative; a constant-majority classifier gets 80% accuracy and is worthless. Report every one of the following:

  • Per-class precision and recall. One pair per class. Macro-average them to get a single number that respects class balance.
  • Macro-F1 (primary metric for imbalanced data). Mean of per-class F1 scores, equally weighted. Use this instead of accuracy when classes are imbalanced.
  • Weighted-F1 (alternative). Same as macro but weighted by class frequency. Report alongside macro-F1 when the imbalance itself has business meaning.
  • Confusion matrix. Raw counts. Always inspect before trusting any scalar metric; it reveals which pair of classes the model confuses.
  • Per-class error samples. Pull 5 wrong predictions per class. Read them. Nothing replaces reading the actual errors.

For severely imbalanced data (> 95-5 ratio), report AUROC and AUPRC instead of accuracy. AUPRC is more sensitive to the minority class, which is what you usually care about (spam, fraud, rare sentiment).

Common bug to avoid. Reporting micro-F1 instead of macro-F1 on imbalanced data gives a number that looks high because it is dominated by the majority class. Macro-F1 forces you to see the minority-class performance.

python
def evaluate(y_true, y_pred):
    tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
    fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
    fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)
    tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
    precision = tp / (tp + fp) if tp + fp else 0
    recall = tp / (tp + fn) if tp + fn else 0
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0
    return {"tp": tp, "fp": fp, "tn": tn, "fn": fn, "precision": precision, "recall": recall, "f1": f1}
Check: Build It0/24
1. In the Naive Bayes code, what does additive (Laplace) smoothing prevent, and what is the teaching default for alpha?Define
2. What is negation scoping?Define
3. What does L2 regularization do in the lesson's logistic regression?Define
4. What is macro-F1?Define
5. What does a confusion matrix report?Define
6. Why include 'NOT_' prefixed tokens during preprocessing?Explain
7. Why do bigrams usually fix negation in a bag-of-words classifier?Explain
8. Why is L2 regularization important for logistic regression on text?Explain
9. Which metric should you make primary when sentiment classes are imbalanced?Explain
10. How does weighted-F1 differ from macro-F1?Explain
11. What should you report instead of accuracy when data is severely imbalanced (worse than 95-5)?Explain
12. What should you always do before trusting a scalar metric like F1?Explain
13. What does apply_negation return for ['i', 'did', 'not', 'like', 'it', ',', 'but', 'fun']?Apply
14. What does apply_negation return for ['nothing', 'special', 'about', 'it', '.', 'loved', 'it']?Apply
15. In train_nb, a class has 12 word tokens in total and the vocabulary has 20 words. With alpha=1.0, what is the stored probability of a word seen 3 times in that class?Apply
16. Using the same counts (12 tokens in the class, 20-word vocabulary, alpha=1.0), what probability does train_nb store for a vocabulary word never seen in that class?Apply
17. evaluate gets tp=8, fp=2, fn=8. What precision and recall does it return?Apply
18. In train_lr, w starts at zeros and b at 0.0. What does preds hold on the first epoch?Apply
19. A test document contains a word that is not in the vocabulary. What does predict_nb do with it?Apply
20. Your sentiment classifier scores 82% accuracy on a test set that is 80% positive. What should you check before trusting that number?Analyze
21. A report shows micro-F1 of 0.91 on a 90-10 dataset, yet the model barely finds the minority class. Why does the number look high?Analyze
22. A Naive Bayes model trained with alpha=0 crashes with a math domain error on a new review. What is the most likely cause?Analyze
23. Logistic regression with l2=0 reaches 100% training accuracy on a sparse text dataset but scores far lower on test data. Which change addresses this?Analyze
24. A reviewer writes 'not good but funny' with no comma. What flaw does apply_negation show on it?Analyze

Use It

scikit-learn does the same thing in six lines, correctly, as long as you set three flags right: keep negations instead of stripping them as stopwords, add bigrams so not_good becomes its own feature, and dampen words that just repeat. Get those three right and you're the difference between a 75%-accurate baseline and an 85%-accurate one on SST-2.

python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
 
pipe = Pipeline([
    ("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, stop_words=None)),
    ("clf", LogisticRegression(C=1.0, max_iter=1000)),
])
pipe.fit(X_train, y_train)
print(pipe.score(X_test, y_test))

Mapped to the code: stop_words=None keeps negations, ngram_range=(1, 2) adds the bigrams, and sublinear_tf=True dampens repeated words.

When to reach for a transformer

  • Sarcasm detection. Classical models fail here. Period.
  • Long reviews where sentiment shifts mid-document.
  • Aspect-based sentiment. "Camera was great but battery was terrible." You need to attribute sentiment to aspects. Transformers or structured output models only.
  • Non-English, low-resource languages. Multilingual BERT gives you a zero-shot baseline for free.

If you need any of the above, skip ahead to phase 7 (transformers deep dive). Otherwise, Naive Bayes or logistic regression on TF-IDF plus bigrams plus negation handling is your 2026 production baseline.

The reproducibility trap (again)

Retraining sentiment models is routine. Re-evaluating them is not. Accuracy numbers reported in papers use specific splits, specific preprocessing, specific tokenizers. If you compare your new model to a baseline without using the identical pipeline, you will get misleading deltas. Always regenerate the baseline on your pipeline, not the paper's number.

Check: Use It0/11
1. What is aspect-based sentiment analysis?Define
2. Why is removing stopwords risky for sentiment analysis?Explain
3. Why does the scikit-learn pipeline set sublinear_tf=True?Explain
4. When should you skip a classical model and reach for a transformer for sentiment?Explain
5. What is the 'reproducibility trap' for sentiment models?Explain
6. In the TfidfVectorizer settings, which argument makes 'not good' its own feature?Apply
7. Accuracy drops sharply on negated reviews after someone switches to the library's default English stopword list. What is the likely cause?Analyze
8. Your new model beats a published baseline by 3 points, but you used a different tokenizer from the paper. What is the sound next step?Analyze
9. You need an English product-review baseline where only 2% of reviews are negative, with no sarcasm requirement. Which design fits the lesson?Design
10. Reviews like 'Camera was great but battery was terrible' must each be scored per feature. Which approach fits?Design
11. You must score reviews in a low-resource, non-English language and have little labeled data. Which starting point does the lesson suggest?Design

Ship It

Save as outputs/prompt-sentiment-baseline.md:

markdown
---
name: sentiment-baseline
description: Design a sentiment analysis baseline for a new dataset.
phase: 5
lesson: 05
---
 
Given a dataset description (domain, language, size, label granularity, latency budget), you output:
 
1. Feature extraction recipe. Specify tokenizer, n-gram range, stopword policy (usually keep), negation handling (scoped prefix or bigrams).
2. Classifier. Naive Bayes for baseline, logistic regression for production, transformer only if the domain needs sarcasm / aspects / cross-lingual.
3. Evaluation plan. Report precision, recall, F1, confusion matrix, and per-class error samples (not just scalars).
4. One failure mode to monitor post-deployment. Domain drift and sarcasm are the top two.
 
Refuse to recommend dropping stopwords for sentiment tasks. Refuse to report accuracy as the sole metric when classes are imbalanced (e.g., 90% positive). Flag subword-rich languages as needing FastText or transformer embeddings over word-level TF-IDF.
Check: Ship It0/1
1. What must the sentiment-baseline prompt refuse to do?Explain
Author's Note

The line people skim past is apply_negation in Step 4, and it's the cheapest win in the whole lesson. I've watched a team burn a sprint arguing about whether to swap in a transformer before anyone tried three lines of NOT_ prefixing on the bag-of-words model they already had running. Try the free fix before you reach for the expensive one. Sentiment rewards that ordering more than most NLP tasks do.

The reproducibility trap isn't hypothetical either. More than once I've seen a reported "regression" turn out to be nothing more than someone re-evaluating with a different tokenizer than the one that produced the original baseline number. Pin your pipeline before you trust a delta, or you'll spend a week chasing a bug that doesn't exist.

Exercises

  1. Easy. Add apply_negation as a preprocessing step in the scikit-learn pipeline and measure the F1 delta on a small sentiment dataset.
  2. Medium. Implement class-weighted logistic regression (pass class_weight="balanced" to scikit-learn, or derive the gradient yourself). Measure the effect on a synthetic 90-10 class imbalance.
  3. Hard. Build a sarcasm detector by training a second classifier on the residuals of the sentiment model. Document your experimental setup. Warn the reader when your accuracy is below chance (chance-level on 2-class sarcasm is ~50%, and most first attempts land there).

Key Terms

TermWhat people sayWhat it actually means
PolarityPositive or negativeBinary label; sometimes extended to neutral or fine-grained (5-star).
Aspect-based sentimentPer-aspect polarityAttribute sentiment to specific entities or attributes mentioned in text.
Negation scopingReversing nearby tokensPrefix tokens after "not" with NOT_ until punctuation.
Laplace smoothingAdding 1 to countsPrevents zero-probability features in Naive Bayes.
L2 regularizationShrinking weightsAdds lambda * sum(w^2) to loss. Essential for sparse text features.

Further Reading

Code

Ships With