AI EngineeringZero to ProductionHome·About·What’s new·Contact
NLP & Transformers · Chapter K3

Sentiment Analysis & Text Classification

Classification — assigning a label to text — is the most common NLP task, and sentiment analysis is its poster child. This chapter builds a classifier the classic way (features + a model), evaluates it honestly, then compares it head-to-head with the LLM approach so you know exactly when each wins.

⏱️ ~55 min🏷️ Foundations + code🎯 Intermediate

Learning objectives

  • Frame problems as text classification (binary, multi-class, multi-label).
  • Build a classic classifier: features (K2) → model → prediction.
  • Evaluate honestly with a train/test split and the right metrics — not just accuracy.
  • Compare classic ML vs LLM classification on cost, data, and accuracy.
  • Choose the right approach for a real classification problem.
You already built the LLM version — now see the classic oneIn Chapter 2 you built a sentiment classifier with a prompt + structured output, and Chapter 5 taught evaluating it. This chapter builds the classic ML version on K2's features, so you can compare the two approaches with real understanding — and know when a lightweight trained model beats calling a frontier LLM.

Framing a classification problem intermediate

Start with the moment this pays off. A product team comes to you drowning in support tickets and says "we need AI to help." That sounds like a research project — until you notice the real request is "sort these into billing, bug, and how-to so the right person sees them first." The instant you spot that, the fog clears: this isn't open-ended generation, it's classification, a task the industry has understood for decades and can measure to two decimal places. Framing is the cheap step that turns a vague ambition into a solved problem — and getting it wrong is how teams end up throwing a frontier LLM at something a five-line model would nail.

Here is the plain idea: classification assigns one or more labels from a fixed, known-in-advance set to a piece of text. The "fixed set" part is the whole game. If you can write down the possible answers before you see the input — spam or not, positive/neutral/negative, this-department or that-one — you are classifying. If the answer is free-form ("summarize this", "write a reply"), you are not; that's generation, a different and usually more expensive tool. Think of classification as sorting mail into a fixed wall of pigeonholes: every letter goes into exactly one slot (or, for multi-label, gets a few stamps), and the slots were built before any mail arrived.

The three shapes in the table below are worth internalizing because they change everything downstream — the model, the loss function, and especially how you score it. Binary is one yes/no decision. Multi-class picks exactly one of N options that are mutually exclusive (a review can't be both positive and negative). Multi-label is the sneaky one: the classes are not exclusive, so a ticket can be tagged both "billing" and "urgent" at once — which means it's really N independent yes/no questions wearing a trench coat, not one N-way choice.

The common beginner mistake is forcing a multi-label problem into a multi-class box. If your tags can co-occur and you build a single "pick one" model, you've quietly made it impossible to ever return two labels, and you'll spend a week confused about why "billing + urgent" tickets keep losing one tag. Decide the shape first, out loud, before you touch a model — it's a one-sentence choice that saves days. A huge share of real NLP work is "just" classification once you frame it right, and framing costs nothing but a moment's honesty about what the answer actually looks like.

Binary spam ✓ not spam pick 1 of 2 Multi-class positive neutral ✓ negative pick exactly 1 of N Multi-label billing ✓ bug urgent ✓ any subset of N the fixed label set is decided before any input arrives Decide the shape first. Binary is one yes/no; multi-class picks exactly one of N exclusive options; multi-label lets several fire at once — really N independent yes/no questions. Forcing multi-label into a multi-class "pick one" is the classic beginner mistake.
🗺️ How to read this diagram

Read the three panels left to right; each is one problem shape, and the green ticked boxes show how many labels a single input is allowed to receive.

  • Binary (left): the label set has just two slots and exactly one lights up — spam or not spam.
  • Multi-class (middle): N mutually-exclusive slots, and still exactly one can win — a review is positive, neutral, or negative, never two at once.
  • Multi-label (right): the slots are independent, so several can light up together — a ticket is tagged both "billing" and "urgent". That is N yes/no questions, not one N-way choice.

In short: Count how many ticks one input can earn — one (binary/multi-class) or many (multi-label) — and you've picked the shape that fixes the model, loss, and metrics downstream.

TypeLabelsExample
Binary2 classesSpam / not spam
Multi-classN mutually-exclusive classesSentiment: positive / neutral / negative
Multi-labelAny subset of N classesA ticket tagged both "billing" AND "urgent"
Many "AI" tasks are classification in disguiseRoute a support ticket, detect toxicity, flag a document type, decide if an email needs a reply, tag content — all classification. Recognizing that gives you a well-understood toolbox (this chapter) and clear evals (Ch 5). It's also why the capstone's risk classification (read_only/reversible/irreversible) is a classification problem at its core (Ch 2, Ch 8).

Lab K3.1 · The classic classifier intermediate

Before LLMs, this is how every spam filter, sentiment dashboard, and news categorizer on earth actually worked — and an enormous number of them still do, quietly, because it's fast and nearly free. It matters that you learn it not as history but as a live option: the day someone asks you to label ten million reviews on a budget, the classic pipeline is the answer, and you'll want to have built one with your own hands. This is the workhorse the whole chapter compares the LLM against, so meet it properly.

The recipe is three steps, and it helps to see it as a translation problem. A model can't do arithmetic on the word "amazing" — it needs numbers. So step one turns text into features (the K2 material: TF-IDF, which is just "how often does each word appear, weighted by how distinctive it is"). Step two feeds those numbers to a model that has already studied thousands of labeled examples and learned which number-patterns go with which label. Step three hands you the label. Picture a language teacher who's graded a mountain of essays: show them a new one and they instantly sense "this reads positive" — not by magic, but from patterns absorbed over all those examples. The classic classifier is that teacher, distilled into a few kilobytes of learned weights.

Here's the insight that separates people who ship good classic classifiers from people who fight them: the features usually matter more than the model. Beginners obsess over "should I use logistic regression or an SVM?" when the real leverage is in the text-to-numbers step — clean tokenization, sensible TF-IDF, maybe swapping in embeddings. Good features make a simple model shine; bad features can't be rescued by a fancy one. That's why K2 came first and why the diagram below spends a whole box on "features." The model is often the least interesting decision you'll make.

One honest caveat so the "simple and fast" framing doesn't oversell it: classic classifiers see words, not meaning. TF-IDF treats "not good" as the words "not" and "good" sitting near each other — it has no built-in sense that "not" flips the sentiment. That literal-mindedness is exactly the weakness the LLM comparison later in this chapter exploits. But for clear-cut, well-separated classes — is this email spam, is this review positive — that literal-mindedness is plenty, and it comes at a thousandth of the cost. Simple, fast, and it's how sentiment analysis worked for years.

text features (K2) trained model label TF-IDF/embeddings → logistic regression / naive Bayes / SVM → class Features → model → label. Vectorize the text (TF-IDF or embeddings, K2), train a standard classifier (logistic regression, naive Bayes, or a linear SVM) on labeled examples, and predict. The representation choice (K2) matters as much as the model — good features make a simple model shine.
🗺️ How to read this diagram

This picture is the whole recipe for a classic (non-LLM) text classifier, read left to right. It's an assembly line: raw text goes in one end, a label comes out the other.

  • text (first box) — the raw sentence you want to label, e.g. "this product is amazing". A computer can't do math on words directly, so it can't stay as text.
  • features (K2) — the text is turned into numbers a model can use. TF-IDF (from the previous chapter) counts which words appear and how distinctive they are, producing a row of numbers per sentence.
  • trained model — a standard classifier (logistic regression, naive Bayes, or SVM — the caption lists them) that has already learned from labeled examples which number-patterns go with which label.
  • label (last box) — the model's answer, one class from a fixed set (e.g. positive). The arrows show data only flows one way, left to right.

In short: The big idea in the caption: good features make a simple model shine. Getting the text-to-numbers step (K2) right often matters more than which model you pick.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab K3.1

Requires: pip install scikit-learn

classify.pyfrom sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(texts, labels, test_size=0.2)

clf = make_pipeline(TfidfVectorizer(), LogisticRegression())   # features + model
clf.fit(X_train, y_train)                    # learn from labeled examples

preds = clf.predict(X_test)                  # label unseen text
print(clf.predict(["this product is amazing"]))   # ['positive']
▶ How this works

This is the classic classifier from start to finish in ~8 lines using scikit-learn, the standard Python machine-learning library. It turns text into numbers, trains a model on labeled examples, then predicts labels for text it has never seen.

  1. The from sklearn... import lines pull in the four tools: TfidfVectorizer (text → numbers), LogisticRegression (the model), make_pipeline (glues steps together), and train_test_split (splits your data honestly).
  2. train_test_split(texts, labels, test_size=0.2) randomly holds back 20% of your data as a test set the model will never train on. The other 80% (X_train, y_train) is for learning; the 20% (X_test, y_test) is saved to grade it fairly later. X is the text, y is the correct labels.
  3. make_pipeline(TfidfVectorizer(), LogisticRegression()) chains the two steps into one object — vectorize, then classify — so you never have to run them separately.
  4. clf.fit(X_train, y_train) is the actual training: the model looks at the 80% of examples and their known labels and learns the word-patterns for each class.
  5. clf.predict(X_test) asks the trained model to label the held-out text. The last line predicts a brand-new sentence — the comment shows it returns ['positive'].

What the output means: preds holds the model's guessed labels for the test set, and the final line prints ['positive'] — the model's label for "this product is amazing". The prediction comes back in a list because you can classify many texts at once.

Try this: Change test_size to 0.5 (a 50/50 split) — you train on less data, so accuracy usually drops. Then change the sample sentence to something negative and see if the predicted label flips.

A few classic models, and when each fitsNaive Bayes — dead simple, strong baseline for text, near-instant. Logistic regression — the reliable default; interpretable, fast, great with TF-IDF. Linear SVM — historically top for text classification. All train in seconds on a laptop and run in microseconds. That speed/cost profile is exactly why they still beat an LLM for some jobs (see below).

Lab K3.2 · Evaluating honestly intermediate

This is the section that separates a demo from a product, and it's the one people skip. Here's the failure that happens over and over: a team trains a classifier, it reports 97% accuracy, everyone celebrates, it ships — and in production it's a disaster, because that 97% was measured wrong or measured on the wrong thing. Evaluating honestly is not a formality you do at the end; it's the discipline that tells you whether you have anything at all. If you take one thing from this whole chapter, take this: a number you can't trust is worse than no number, because it makes you confident while you're wrong.

Two ideas do all the work. The first is where you measure: always on data the model has never seen. A model graded on its own training data is like a student who was given the exam answers to study — of course they ace it, and of course that tells you nothing about a real test. The second is which number you look at, and this is where the famous trap lives. Accuracy — plain "percent correct" — sounds like the obvious metric, and on balanced data it's fine. But the moment your classes are lopsided, accuracy becomes a liar, and most real problems (spam, fraud, defects, the dangerous-action class in the capstone) are wildly lopsided.

The fix is to split "correct" into two questions that pull in opposite directions. Precision asks: of the things I flagged, how many were real? (High precision = few false alarms.) Recall asks: of the things that were actually there, how many did I catch? (High recall = few misses.) A useful analogy is a smoke detector. Crank its sensitivity to max and it catches every fire (perfect recall) but shrieks at burnt toast (terrible precision); make it sleepy and it never false-alarms (perfect precision) but might miss a real fire (terrible recall). You can't max both — pushing one usually drops the other — so you pick based on which mistake actually hurts. F1 is the single number that balances the two (their harmonic mean, which punishes lopsidedness), and the confusion matrix is the honest picture underneath it all: a grid showing exactly which classes get mistaken for which, so you see where you fail, not just how often. Use the right metric, on data the model hasn't seen — those two habits are the entire foundation of trustworthy evaluation, and the table below tells you which metric to reach for when.

predicted → spam not spam actual ↓ spam not spam TPcaught FNmissed spam FPfalse alarm TNcorrect pass precision TP / (TP+FP) recall TP/(TP+FN) the diagonal (green) is right; the off-diagonal (red) is where you fail Precision reads a column, recall reads a row. Precision (of what you flagged as spam, how much was real) runs down the predicted-spam column; recall (of the real spam, how much you caught) runs across the actual-spam row. Push one up and the other usually drops.
🗺️ How to read this diagram

It's a 2×2 grid: columns are what the model predicted, rows are what was actually true. Every prediction lands in exactly one of the four cells.

  • The two green cells on the diagonal are correct — true positives (spam you caught) and true negatives (good mail you passed). More green is better.
  • The two red off-diagonal cells are the mistakes — a false negative is spam you missed, a false positive is good mail you wrongly flagged. The confusion matrix's whole job is to show which of these two errors you're making.
  • The blue down-arrow is precision (it divides TP by everything in that predicted column); the amber across-arrow is recall (it divides TP by everything in that actual row). Same TP cell, two different denominators — that's why the two metrics pull apart.

In short: Read a column for precision, a row for recall; the accuracy trap hides because it lumps all four cells into one number and lets a huge TN swamp a tiny, ignored TP.

The accuracy trapIf 95% of emails aren't spam, a model that predicts "not spam" for everything scores 95% accuracy — and catches zero spam. Accuracy is meaningless on imbalanced classes. You need precision and recall to see what's really happening. This is the single most important idea in classification evaluation — and it's the same lesson as Chapter 5's evals.
MetricAnswersCare when…
Accuracy% correct overallClasses are balanced (and only then)
PrecisionOf predicted-positive, how many were right?False positives are costly (flagging good email as spam)
RecallOf actual-positives, how many did we catch?False negatives are costly (missing actual fraud)
F1Balance of precision & recallYou need one number and both matter
Confusion matrixWhich classes get confused for whichAlways — it shows where it fails
Lab K3.2

Requires: pip install scikit-learn

evaluate.pyfrom sklearn.metrics import classification_report, confusion_matrix

preds = clf.predict(X_test)
print(classification_report(y_test, preds))   # precision, recall, F1 per class
print(confusion_matrix(y_test, preds))        # what gets confused for what
▶ How this works

Training a model is easy; knowing whether it's actually good is the real skill. These two scikit-learn functions grade the model on the held-out test set — the data it never trained on — so the score reflects how it'll do on real, unseen text.

  1. preds = clf.predict(X_test) gets the model's guesses for the test set (the same 20% you set aside in Lab K3.1). We now compare these guesses to the true answers y_test.
  2. classification_report(y_test, preds) prints, for each class, three numbers: precision (of the items it labeled X, how many really were X), recall (of the items that truly were X, how many it caught), and F1 (a single score balancing the two). This is why plain accuracy isn't enough — see the 'accuracy trap' note above.
  3. confusion_matrix(y_test, preds) prints a grid showing which classes get mistaken for which — e.g. how often 'neutral' was wrongly called 'positive'. It shows you where the model fails, not just how often.

What the output means: Two printouts: a table of precision/recall/F1 per class (numbers from 0 to 1, higher is better), and a square grid where the diagonal is correct predictions and off-diagonal cells are mistakes. Reading the grid tells you exactly which two classes the model confuses.

Try this: After running, look for the row with the lowest recall — that's the class the model misses most. In spam detection, low recall on 'spam' means real spam is slipping through, which is usually worse than the occasional false alarm.

Never evaluate on training dataA model scored on data it trained on will look great and fail in production — it memorized, it didn't generalize. Always hold out a test set it never saw (and a separate validation set if you're tuning). This train/test discipline is the foundation of trustworthy evaluation — and it maps directly onto Chapter 5's golden set and the eval gate (O3/I4).

Classic ML vs LLM classification advanced

This is the decision that lands on your desk in real life, and getting it wrong costs real money. Someone needs text classified. You now know two completely different ways to do it: train a classic model on labeled examples, or write a prompt and ask an LLM. The reflex in 2026 is "just use the LLM" — it's easy, it's impressive, it needs no training data. And for a lot of tasks that reflex is right. But choose the LLM for a high-volume job that a logistic-regression model would handle, and you can burn a thousand times the budget for the same accuracy. Choose the classic model for a nuanced, low-data task and you'll spend weeks hand-labeling data to build something that underperforms a five-minute prompt. Neither is universally better — the skill is knowing which trade you're making.

The cleanest way to hold this in your head is that the two approaches have mirror-image strengths: nearly every advantage of one is a weakness of the other. A useful analogy is hiring a specialist temp versus a brilliant generalist consultant. The classic model is the specialist you trained yourself — cheap to run, lightning-fast, does the exact one job you drilled it on, and gives the identical answer every time. But it's rigid: it only knows what you taught it, and changing its job means retraining from scratch. The LLM is the consultant on call — needs no training, understands sarcasm and context, handles brand-new categories the moment you describe them in the prompt. But it's slower, costs a fee per question, and might phrase things a little differently each time.

The variables that decide it are volume, budget, data, and nuance — and they interact. Low volume tilts toward the LLM because per-call cost barely matters when you're doing ten a day; sky-high volume tilts hard toward the classic model because a fraction-of-a-cent per item, multiplied by millions, becomes a real line on the bill while the trained model runs for essentially nothing. Plenty of labeled data unlocks the classic route; little or none forces the LLM's zero/few-shot magic. Clean, well-separated classes are classic-model territory; sarcasm, subtle context, and fuzzy boundaries are where the LLM earns its keep. The mistake to avoid is treating this as a technology preference ("we're an LLM shop now") instead of a per-task calculation. Read the scorecard below and the table under it as a way to make that calculation explicit — and remember the two aren't mutually exclusive, which the hybrid pattern right after exploits.

Classic ML needs labeled data fast · cheap · tiny deterministic retrain to change LLM (prompt) zero/few-shot — no training slower · pricier handles nuance change = edit the prompt Two ways to classify, different profiles. Classic ML needs labeled training data but is tiny, instant, cheap, and deterministic. An LLM needs no training (zero/few-shot from a prompt), handles nuance and new classes on the fly, but costs more per call and is slower. The right choice depends on volume, budget, data, and how much nuance the task needs.
🗺️ How to read this diagram

This is a side-by-side scorecard of the two ways you can now classify text: a classic trained model (left) versus asking an LLM with a prompt (right). Neither wins everywhere — the point is to see the trade-offs at a glance.

  • Left — Classic ML. It needs labeled data to train, but once trained it's tiny, fast, and cheap to run, and gives the same answer every time (deterministic). The catch: to change what it does, you must retrain it.
  • Right — LLM (prompt). It needs no training data (zero/few-shot: you just describe the task or show a few examples), and it handles subtle, nuanced language well. The catch: it's slower and costs more per item.
  • Read each row as a trade: 'needs labeled data' vs 'no training', 'fast·cheap·tiny' vs 'slower·pricier', 'deterministic' vs 'handles nuance', 'retrain to change' vs 'edit the prompt'. Every strength on one side is a weakness on the other.

In short: Rule of thumb from the caption: pick the classic model when you have labels + huge volume + tight budget; reach for the LLM when you have little data or need nuance. The table just below the diagram spells out each case.

FactorClassic ML winsLLM wins
Training dataYou have thousands of labeled examplesYou have few/none — zero/few-shot
Volume & costMillions of items, tight budget/latencyModerate volume; per-call cost acceptable
NuanceClear, well-separated classesSarcasm, context, subtle categories
FlexibilityFixed label set, rarely changesLabels/criteria change often (edit the prompt)
Explainability/determinismRegulated, must be reproducibleFlexibility matters more than determinism
The pragmatic hybridA common production pattern: use the LLM to bootstrap labels (few-shot classify a few thousand examples), then train a cheap classic model on those labels to run at scale for pennies. You get the LLM's nuance encoded into a model that's fast and cheap enough for millions of items. It's model-tiering (C1) taken to its logical end — the frontier model teaches; the small model serves.

The LLM classifier, recalled advanced

The stakes here are conceptual: seeing these two side by side is what makes the trade-off real instead of abstract. You could nod along to "the LLM needs no training data" and not feel it — until you look at the code and realize the entire classifier is a prompt and a schema. There's no .fit(), no training loop, no labeled dataset, no feature engineering. Where the classic pipeline needed thousands of examples before it could say a single word, this version classifies its first sentence cold. That's the superpower and the price tag in one glance, and it's why we recall it here rather than in a separate lesson.

The mental shift is that the "model" isn't something you build — it already exists, pre-trained on a large slice of the internet, and you're just describing the job in words. Think of the difference between training a new employee for weeks (classic ML) versus handing a competent contractor a one-line brief and getting work back immediately (the LLM). The contractor knew how to read sentiment before you showed up; your job is only to specify what you want and to constrain the answer so it's safe to use downstream. That constraint is the quietly critical part: left to free-form, the model might reply "This seems quite positive!" which is useless to code that expects one of three exact strings.

Two ideas from Chapter 2 do the heavy lifting and are worth naming before you read the code. First, structured output: by declaring a Literal of the allowed labels, you make it impossible for the model to return an off-menu answer — the label comes back as a clean, validated value, not a sentence you have to parse. Second, model tiering: sentiment is an easy task, so you deliberately pick a small, cheap model (Haiku) rather than a frontier one — paying frontier prices for "is this review happy?" is exactly the reflex this chapter argues against. Keep both in view as you read; they're what make the LLM route production-grade rather than a demo. You built this in Chapter 2 — worth seeing side by side with the classic version.

classic: build the model first 1000s labeled .fit() train text in label out LLM: no training at all text in pre-trained Literal["pos","neu","neg"] validated label no data → no classifier left needs a dataset + a training loop; right is just a prompt + a schema Same task, one route skips the entire build. The classic pipeline can't say a word until you've gathered thousands of labels and run .fit(); the LLM classifies its first sentence cold, and a Literal enum forces the answer back on-menu. That's the superpower and the price tag in one glance.
🗺️ How to read this diagram

Two mini-pipelines sit side by side; read each top-to-bottom, then compare left against right — the difference is the whole point of this section.

  • Left (classic). Before it can label anything, the top row shows the up-front cost: thousands of labeled examples fed into a .fit() training step. Only then can the bottom row take text in and hand a label out.
  • Right (LLM). There is no training row. Text goes straight to an already-pre-trained model, and the Literal[...] box is the structured-output schema that forces the reply to be one of the three allowed words — ending in a clean, validated label.
  • The amber note under the left pipeline — no data → no classifier — is the trade: the classic route buys speed and low cost with a heavy up-front labeling bill, while the right route pays per call but starts working immediately.

In short: The classic side has an extra "build the model" stage the LLM side simply doesn't have — that missing stage is exactly why "no training data required" is the LLM's headline advantage.

Uses the client / objects set up earlier in this lesson, and needs the matching API key set (ANTHROPIC_API_KEY for Claude, OPENAI_API_KEY for OpenAI). Run the earlier blocks first.

Zero-training LLM classification — in Claude or OpenAI

The Sentiment model and the Literal enum are identical for both vendors — only the call differs. In Claude you call client.messages.parse(output_format=Sentiment) and read r.parsed_output; in OpenAI the same thing is client.responses.parse(text_format=Sentiment) and you read r.output_parsed. Either way the schema forces an on-menu label. Toggle the tab to switch SDK.

llm_classify.pyfrom pydantic import BaseModel
from typing import Literal

class Sentiment(BaseModel):
    label: Literal["positive", "neutral", "negative"]
    confidence: float

r = client.messages.parse(model="claude-haiku-4-5", max_tokens=128,   # small model — easy task (C1)
    messages=[{"role":"user","content": "honestly the best purchase this year"}],
    output_format=Sentiment)
print(r.parsed_output.label)     # positive — no training data required
Two things transfer straight from Chapter 2Structured output (a Literal enum) makes the label safe to consume — the model literally can't return an off-set class. And model tiering: sentiment is easy, so use Haiku, not the frontier model (C1). Even when you choose the LLM route, the classic evaluation discipline (K3.2 — test set, precision/recall/F1) is exactly how you check it (Ch 5).
▶ How this works

This is the LLM version of the exact same task — the sentiment classifier you built back in Chapter 2 — shown here so you can compare it with the classic one above. Notice there's no training set and no feature engineering: the entire 'model' is a prompt plus a required output shape.

  1. class Sentiment(BaseModel) uses Pydantic to declare the exact answer shape you want back. label: Literal["positive", "neutral", "negative"] forces the answer to be one of those three words — the model literally cannot return anything else — and confidence: float asks for a certainty score.
  2. The .parse call sends the text to the model and, thanks to the schema, makes it reply in your Sentiment shape instead of a free-form paragraph — client.messages.parse(..., output_format=Sentiment) in Claude, client.responses.parse(..., text_format=Sentiment) in OpenAI (toggle the tab above).
  3. The model argument deliberately picks a small, cheap tier (claude-haiku-4-5 or an equivalent fast OpenAI model) — sentiment is an easy task, so you don't pay for a frontier one (model tiering, C1).
  4. The parsed result (r.parsed_output.label in Claude, r.output_parsed.label in OpenAI) pulls the clean, validated label out of the reply as a normal Python value you can store or branch on — no parsing text by hand.

What the output means: positive prints. There was no training step at all — the model classified "honestly the best purchase this year" straight from the prompt, which is the whole point of the comparison with the classic pipeline above.

Try this: Swap the message to something mixed like "it works but shipping was slow" and watch the confidence value — ambiguous text should lower it. Even for the LLM route, you'd still grade it with the K3.2 metrics (test set, precision/recall/F1).

Choosing an approach advanced

Everything above becomes useless if you freeze at the actual decision, so here it is boiled down to a checklist you can run in your head. The sketch below isn't a rigid algorithm to memorize; it's the order in which a seasoned engineer asks questions, cheapest option first. That ordering is the real lesson: you don't start by reaching for the most powerful tool, you start by reaching for the simplest one that could possibly work and only climb the ladder when the task forces you to.

Read it top to bottom as a series of "can I get away with something cheaper?" gates. First: could a plain keyword or regex rule settle it? If every urgent ticket literally contains the word "outage," you may not need machine learning at all — a rule is free, instant, and perfectly explainable, and it's embarrassing how often people skip straight past it. If rules can't capture it but you have lots of labels and high volume, the classic model wins on cost. If you have little data or the classes are nuanced, the LLM's zero/few-shot ability justifies its price. And if you have both nuance and scale, the hybrid — LLM to label, classic model to serve — gives you most of the quality at a fraction of the running cost.

The one gate with no elif, the one that fires no matter which branch you land in, is the last: evaluate on a held-out test set with the right metric. This is deliberate. Whichever tool you pick, you are not done until you've measured it honestly (K3.2) — the choice of classifier is a guess until the numbers confirm it. Treat the sketch as advisory and the evaluation as mandatory, and you'll rarely choose wrong.

ask the cheapest question first ↓ regex/keyword rule? rules (K1) labels + high volume? classic ML little data / nuanced? LLM few-shot nuance AND scale? LLM → distill ALWAYS: evaluate on a held-out setright metric — no branch skips this green/purple boxes = the tool you pick · red bar = the mandatory gate stop at the first "yes" — that's the cheapest tool that works Climb only as high as the task forces you. Each gate asks "can I get away with something cheaper?"; you take the first branch that fits — rule, classic model, LLM, or hybrid. Whatever you land on, the red bar fires unconditionally: measure it honestly or you haven't chosen, you've guessed.
🗺️ How to read this diagram

Read it strictly top-to-bottom. Each grey box is a yes/no question, ordered cheapest option first; the moment one answers "yes," you branch right to that tool and stop descending.

  • The four grey question boxes down the middle are the gates, in cost order: a plain rule, then a classic model, then an LLM, then the hybrid. You only fall to the next question if the current one is "no."
  • The boxes on the right are the tools you'd pick — green for the cheap/deterministic options (rules, classic ML) and purple for the LLM routes. Taking the first "yes" means you never pay for a heavier tool than the task needs.
  • The red bar at the bottom is the one gate with no branch around it: whichever tool you chose, you must evaluate it on a held-out set with the right metric. That's why the arrow into it is unconditional.

In short: Descend until the first "yes," pick that tool, then always run the red evaluation gate — the choice is a guess until the numbers confirm it.

Classification decision sketch if a regex/keyword rule fully captures it → rules (K1) — cheapest elif lots of labeled data + huge volume + tight budget → classic ML classifier elif little/no labeled data OR nuanced classes → LLM (zero/few-shot) elif nuance AND scale → LLM to label → train classic model always → evaluate on a held-out test set with the right metric
Same "match the tool to the task" refrainThis is model tiering (C1), build-vs-adopt (I1), and classic-vs-LLM (K1) all over again. The reflex "throw an LLM at it" is often wrong for classification: if you have the labels and the volume, a logistic-regression model is 1000× cheaper and just as accurate on clear-cut classes. Reach for the LLM when nuance or missing data justifies it.

Common pitfalls expert

Every row in this table is a mistake that has shipped to production and cost someone a bad week — they're not hypothetical foot-guns, they're the specific ways classification projects go quietly wrong. The reason to study them as a set is that they share a personality: each one looks fine right up until it doesn't. The model reports a great score, the demo works, everyone moves on — and the failure only surfaces later, in the numbers that matter, when it's expensive to unwind.

Notice the two villains that recur. The first is measuring the wrong thing: reporting accuracy on imbalanced data, or grading on training data, both give you a comforting number that describes a world you don't live in. The fix in both cases is the K3.2 discipline — right metric, unseen data — which is why that section was the heart of the chapter. The second villain is reaching for the wrong-sized tool: defaulting to an LLM for high-volume work you have labels for, or using a frontier model where a small one or a classic one would do. That's the cost-blindness this whole chapter is trying to cure. Skim the table now, then treat it as a pre-flight checklist you actually run before you call a classifier "done" — most of these are one honest question away from being caught.

villain 1 · measuring the wrong thing accuracy onimbalanced data grade ontrain data villain 2 · wrong-sized tool LLM athigh volume frontierfor easy task root: wrong metric/ wrong data root: cost-blindtool choice fix: right metric · unseen data· match tool to the task the pre-flight checklist six red pitfalls → two root causes → one habit that catches them all Six foot-guns, two villains. Every pitfall in the table below is either measuring the wrong thing (bad metric or leaked data) or reaching for the wrong-sized tool (cost-blind model choice). Name the villain and the fix falls out — the K3.2 discipline plus honest tool-matching.
🗺️ How to read this diagram

Read it top-to-bottom: the scattered red pitfalls at the top funnel down into two named root causes, which then merge into a single habit that defends against all of them.

  • The top red boxes are the individual mistakes from the table — but they're grouped: the left pair is about measurement, the right pair is about tool size.
  • The two purple "root" boxes name the villain behind each group — a wrong metric / leaked data on the left, a cost-blind tool choice on the right. Most pitfalls are just a face of one of these two.
  • The green box at the bottom is the payoff: the single pre-flight checklist — right metric, unseen data, tool matched to the task — that catches every red box above it. Both arrows converge on it.

In short: Don't memorize six separate warnings; ask two questions — "am I measuring the right thing?" and "is this the right-sized tool?" — and the whole table is covered.

PitfallFix
Reporting accuracy on imbalanced dataUse precision/recall/F1 and a confusion matrix
Evaluating on training dataHold out a test set the model never saw
Defaulting to an LLM for high-volume classificationA classic model is far cheaper if you have labels
Free-text LLM labels breaking downstreamConstrain with a Literal enum (Ch 2)
Frontier model for an easy classify taskUse a small model (C1) — or a classic one
Ignoring class imbalance in trainingRebalance, reweight, or resample; watch recall

Exercises expert

Exercise K3.1 — Classic sentiment classifier

Context: A TF-IDF + logistic-regression pipeline is the workhorse classic sentiment classifier; its confusion matrix tells you exactly which classes it muddles.

Your task: Train the TF-IDF + logistic-regression pipeline on a labeled sentiment dataset, then report per-class precision/recall/F1 and the confusion matrix.

Requirements:

  • Fit TF-IDF features into a logistic-regression classifier
  • Report precision, recall, and F1 per class
  • Produce the confusion matrix
  • Identify the most-confused class and reason about why

💡 Hint: The off-diagonal cells of the confusion matrix point straight at the classes the model conflates.

Exercise K3.2 — Classic vs LLM, head to head

Context: On the same held-out set, accuracy is often a wash — it is cost×volume, not accuracy, that flips the deploy decision between a classic model and an LLM.

Your task: Score your classic classifier and the LLM classifier on one held-out test set with identical metrics, then compare accuracy, cost per 1000 items, and latency.

Requirements:

  • Evaluate both models on the identical test set and metrics
  • Measure cost per 1000 items and per-item latency for each
  • Decide which to deploy at 10 items/day
  • Decide which to deploy at 10M items/day and explain the flip

💡 Hint: At tiny volume the LLM's zero-training convenience wins; at scale the classic model's near-zero marginal cost decides it.

Show what to look for

On clear-cut sentiment both may score similarly, but the classic model costs ~nothing and runs in microseconds — decisive at 10M/day. At 10 items/day the LLM's zero-training convenience wins. The metric that flips the decision is cost×volume, not accuracy.

Exercise K3.3 — Bootstrap-and-distill

Context: The hybrid pattern uses an LLM to manufacture labels, then trains a cheap classic model on them — buying most of the LLM's quality at a fraction of the cost.

Your task: Use the LLM to label 500 unlabeled examples, train a classic classifier on those labels, and evaluate it against a human-labeled test set.

Requirements:

  • Have the LLM label a batch of previously-unlabeled examples
  • Train a classic classifier on the LLM-generated labels
  • Evaluate on a separate human-labeled test set
  • Report how close the distilled model gets to the LLM

💡 Hint: This is bootstrap-and-distill: the LLM is the teacher, the classic model is the cheap student you actually ship.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Frame the problem: labels & featuresBeginner

Context: Framing turns a task into (features → label). Before any model, you define the label set and check class balance, because a rare class shapes every downstream choice.

Your task: Given raw examples, produce a clean (text, label) dataset and report the class balance.

Requirements:

  • Separate the examples into parallel texts and labels
  • Report the count per class (e.g. with Counter)
  • Flag rare classes at framing time
  • Note the total number of examples

💡 Hint: Class balance drives stratified splits and possible resampling later — decide it now, not after a model surprises you.

Show solution

Framing first: define the label set and check balance. Runnable:

from collections import Counter

raw = [("Refund my order now!", "billing"),
       ("App crashes on login", "bug"),
       ("How do I reset password", "howto"),
       ("Charged twice this month", "billing"),
       ("Login button does nothing", "bug")]

texts  = [t for t, _ in raw]
labels = [y for _, y in raw]
print("classes:", dict(Counter(labels)))
print("n examples:", len(texts))
# imbalance check: billing=2 bug=2 howto=1 -- flag rare classes early

Class balance drives everything downstream: a rare class needs stratified splits and possibly resampling, decided at framing time.

Exercise 2 · A multinomial Naive Bayes by handIntermediate

Context: Naive Bayes is the classic text classifier: class priors times per-word likelihoods, with add-1 smoothing so an unseen word never zeroes out a class.

Your task: Implement multinomial Naive Bayes — training (priors + smoothed word likelihoods) and prediction — in the standard library.

Requirements:

  • Estimate class priors from the label counts
  • Estimate per-class word likelihoods with add-1 (Laplace) smoothing
  • Predict by summing log-probabilities to avoid underflow
  • Return the highest-scoring class for a new document

💡 Hint: Work in log-space and smooth every likelihood; the smoothing is what keeps an unseen word from sending a class probability to zero.

Show solution

Train counts, apply Laplace smoothing, predict via log-probabilities. Runnable:

import math
from collections import Counter, defaultdict

def train_nb(data):
    classes = Counter(y for _, y in data)
    word_counts = defaultdict(Counter)
    vocab = set()
    for text, y in data:
        for w in text.lower().split():
            word_counts[y][w] += 1; vocab.add(w)
    return classes, word_counts, vocab

def predict_nb(text, model):
    classes, wc, vocab = model
    total = sum(classes.values()); V = len(vocab)
    best, best_lp = None, -math.inf
    for c in classes:
        lp = math.log(classes[c] / total)
        n_c = sum(wc[c].values())
        for w in text.lower().split():
            lp += math.log((wc[c][w] + 1) / (n_c + V))   # add-1 smoothing
        if lp > best_lp:
            best_lp, best = lp, c
    return best

data = [("cheap meds buy now", "spam"), ("win money now", "spam"),
        ("meeting at noon", "ham"), ("lunch tomorrow", "ham")]
m = train_nb(data)
print(predict_nb("cheap money now", m))   # spam
print(predict_nb("meeting lunch", m))      # ham

Naive Bayes is fast, tiny, and interpretable — a strong baseline that classic NLP still reaches for on small labeled datasets.

Exercise 3 · Evaluate honestly: precision/recall/F1Advanced

Context: Accuracy lies on imbalanced data — a majority-only predictor can score 90% while catching zero minority cases. Per-class precision, recall, and F1 expose what accuracy hides.

Your task: Implement a confusion matrix and per-class precision, recall, and F1 from true/predicted label lists.

Requirements:

  • Count TP, FP, and FN per class
  • Precision = TP / (TP + FP); recall = TP / (TP + FN)
  • F1 is the harmonic mean of precision and recall
  • Guard against zero denominators and report metrics per class

💡 Hint: Report each class separately: a strong overall accuracy can hide a minority class whose recall is near zero.

Show solution

Compute TP/FP/FN per class, then derive the metrics. Runnable:

from collections import Counter

def metrics(y_true, y_pred):
    labels = sorted(set(y_true) | set(y_pred))
    out = {}
    for c in labels:
        tp = sum(t == c and p == c for t, p in zip(y_true, y_pred))
        fp = sum(t != c and p == c for t, p in zip(y_true, y_pred))
        fn = sum(t == c and p != c for t, p in zip(y_true, y_pred))
        prec = tp / (tp + fp) if tp + fp else 0.0
        rec  = tp / (tp + fn) if tp + fn else 0.0
        f1 = 2*prec*rec/(prec+rec) if prec+rec else 0.0
        out[c] = dict(precision=round(prec,2), recall=round(rec,2), f1=round(f1,2))
    return out

y_true = ["spam","spam","ham","ham","ham","ham"]
y_pred = ["spam","ham", "ham","ham","ham","spam"]
for c, m in metrics(y_true, y_pred).items():
    print(c, m)
# accuracy hides that spam recall is only 0.5 -- per-class metrics expose it

On a 90/10 split, a "predict majority" model scores 90% accuracy while catching zero minority cases — which is why you report per-class precision/recall/F1.

Exercise 4 · Stratified train/test splitExpert

Context: Honest evaluation needs a split that preserves class ratios in every fold; a naive random split can starve the test set of a rare class entirely.

Your task: Implement a stratified train/test split in the standard library and verify the class balance holds in both folds.

Requirements:

  • Group examples by label first
  • Split each label group by the same fraction
  • Combine the per-class slices into train and test sets
  • Verify the class ratios are preserved in both folds

💡 Hint: Stratify by splitting within each class, so the rare class shows up in the test fold at the same rate as in the data.

Show solution

Group by label, split each group by the same fraction, then combine. Runnable:

import random
from collections import Counter, defaultdict

def stratified_split(data, test_frac=0.4, seed=0):
    rng = random.Random(seed)
    by_class = defaultdict(list)
    for item in data:
        by_class[item[1]].append(item)
    train, test = [], []
    for c, items in by_class.items():
        items = items[:]; rng.shuffle(items)
        cut = round(len(items) * test_frac)
        test += items[:cut]; train += items[cut:]
    return train, test

data = [("t%d" % i, "A" if i % 3 else "B") for i in range(15)]
tr, te = stratified_split(data)
print("train balance:", dict(Counter(y for _, y in tr)))
print("test balance :", dict(Counter(y for _, y in te)))
# ratios preserved in both folds, unlike a naive random split

A random split can starve the test set of a rare class; stratification keeps evaluation honest by preserving the class distribution in every fold.

Exercise 5 · Classic vs LLM classifier: the trade routerProfessional

Context: The lesson contrasts a trained classic model (fast, cheap, needs labels) with an LLM zero-shot classifier (no labels, slower, pricier). A router makes the labels-vs-latency-vs-volume trade explicit.

Your task: Encode classifier_choice(...) that routes between a trained classic model and an LLM based on available labels, latency, volume, and label churn.

Requirements:

  • Ample labels plus a tight latency budget favours a trained classic model
  • Scarce labels or a frequently-changing label set favours an LLM zero/few-shot
  • Very high daily volume tips toward the cheap trained model at scale
  • Otherwise, use the LLM to bootstrap labels then distill to a classic model

💡 Hint: The pragmatic pattern: let the LLM bootstrap labels or cover the long tail, then distill into a cheap classic model once the label set stabilizes.

Show solution

Route on labels-available, latency, volume, and label churn. Runnable:

def classifier_choice(labeled_examples, latency_ms, volume_per_day, labels_change_often):
    if labeled_examples >= 500 and latency_ms < 50:
        return "trained classic model (NB/linear/small transformer)"
    if labeled_examples < 50 or labels_change_often:
        return "LLM zero/few-shot (no training data needed)"
    if volume_per_day > 1_000_000:
        return "trained model (cost at scale)"
    return "LLM to bootstrap labels, then distill to a classic model"

print(classifier_choice(2000, 20, 5_000_000, False))
print(classifier_choice(10, 400, 500, True))

The pragmatic pattern: use an LLM to bootstrap labels or handle the long tail, then distill into a cheap classic model once you have data and the label set stabilizes.

Exercise 6 · Ship a text classifier with a confidence gateIndustry scenario

Context: A shippable classifier abstains when unsure and routes to a human — turning "always guess" into "answer when confident, escalate when not" and bounding the error the business sees.

Your task: Wrap Naive Bayes to return a label plus a normalized confidence, and defer low-confidence cases to human review.

Requirements:

  • Convert the per-class log-probabilities into probabilities via a stable softmax
  • Take the top class and its normalized confidence
  • Abstain and route to a human when confidence falls below a threshold
  • Return both the decision and the confidence score

💡 Hint: Subtract the max log-prob before exponentiating for a numerically stable softmax, then gate the answer on the resulting top probability.

Show solution

Turn log-probs into a softmax confidence; abstain below a threshold. Runnable:

import math
from collections import Counter, defaultdict

def train_nb(data):
    classes = Counter(y for _, y in data); wc = defaultdict(Counter); vocab = set()
    for text, y in data:
        for w in text.lower().split():
            wc[y][w] += 1; vocab.add(w)
    return classes, wc, vocab

def predict_conf(text, model, threshold=0.65):
    classes, wc, vocab = model
    total = sum(classes.values()); V = len(vocab)
    lps = {}
    for c in classes:
        lp = math.log(classes[c] / total); n_c = sum(wc[c].values())
        for w in text.lower().split():
            lp += math.log((wc[c][w] + 1) / (n_c + V))
        lps[c] = lp
    m = max(lps.values())
    exps = {c: math.exp(v - m) for c, v in lps.items()}   # softmax, stable
    Z = sum(exps.values())
    probs = {c: e / Z for c, e in exps.items()}
    label = max(probs, key=probs.get)
    if probs[label] < threshold:
        return ("ABSTAIN -> human review", round(probs[label], 2))
    return (label, round(probs[label], 2))

data = [("buy cheap now", "spam"), ("win prize now", "spam"),
        ("lunch at noon", "ham"), ("meeting tomorrow", "ham")]
m = train_nb(data)
print(predict_conf("cheap prize now", m))
print(predict_conf("noon meeting lunch", m))
print(predict_conf("hello", m))            # low confidence -> abstain

A confidence gate is what makes a classifier shippable: it turns "always guess" into "answer when sure, escalate when not", bounding the error the business sees.

✓ Checkpoint — you can move on when you can…

  • Frame a problem as binary/multi-class/multi-label classification.
  • Build a classic features→model classifier.
  • Evaluate with a test set and precision/recall/F1 (not just accuracy).
  • Compare classic ML vs LLM classification on the key factors.
  • Choose (and justify) an approach — including the hybrid.
🏗️ Toward the capstoneThe capstone's risk classifier — is this action read_only, reversible, or irreversible? — is a text/structured classification problem, and it's evaluated exactly as this chapter teaches: a held-out set of real actions with precision/recall on the dangerous class (you cannot afford a false "safe"). Classic classification discipline is what keeps that safety gate honest (Ch 8d). Next, K4: how neural networks learned to model sequences. Next: neural sequence models →

Knowledge check check yourself

✓ Knowledge check

What is the 'accuracy trap' on imbalanced data, and which metrics does the lesson say you need instead?

Show answer
If 95% of emails aren't spam, a model that always predicts 'not spam' scores 95% accuracy while catching zero spam — accuracy is meaningless on imbalanced classes. You need precision and recall (and a confusion matrix) to see what's really happening.
✓ Knowledge check

The lesson describes a 'bootstrap-and-distill' hybrid pattern. What is it, and why is it attractive at very high volume?

Show answer
Use the LLM to few-shot label a few thousand examples, then train a cheap classic model on those labels to run at scale. You encode the LLM's nuance into a model that's fast and cheap enough to serve millions of items — model tiering taken to its end: the frontier model teaches, the small model serves.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in