Skip to content
ML Academy

Core day 41 of 60 · Week 9 · Part II · application depth

NLP Foundations

The support desk has 60 labelled complaint messages and 600 unlabelled ones from the same inbox, and a vendor offers an "AI language model" to route them. What is the strongest simple text baseline, what would a dense representation have to add to beat it, and how do you split the data so that near-duplicate messages cannot flatter the result?

By the end: For a small text-classification task you fit a TF-IDF plus logistic regression baseline, compare it with an LSA (SVD) representation on familiar and unfamiliar phrasing, and show with a grouped split how near-duplicate messages inflate an accuracy number.

At a glance

Turn text into weighted word counts, fit a linear model as the baseline, and only then ask whether a dense representation adds anything.

  • Tokens to columns

    Split text into tokens; every vocabulary word becomes one sparse column.

  • TF-IDF

    Count times rarity: words in every message fade, specific words dominate.

  • Linear baseline

    Logistic regression on TF-IDF is cheap, fast and readable: run it first.

  • Vocabulary mismatch

    Unseen words get no weight; LSA or encoders help only if a corpus links them.

  • Grouped split

    Split by ticket or template; near-duplicates across a random split inflate scores.

Recall · 5 min

No reviews due? Answer from memory. (1) Why must a scaler or encoder be fit on the training rows only (Day 9)? (2) What did a strong simple baseline have to beat on Day 1? (3) In your own words, what does an embedding give you that a one-hot column does not (Day 38)? Today's vectorizer is a preprocessing step with the same boundary rule.

Open due reviews

Sign in to record this block.

Concept · 10 min

A model cannot read, so a message must become numbers. Counting words (Bag-of-words), weighting rare ones up (TF-IDF) and fitting a linear classifier gives a strong, cheap, readable baseline. It fails where the words change (Vocabulary mismatch); a dense representation helps only if its corpus links the old and new words.

A TF-IDF baseline fails exactly where the words change, and a dense representation helps only if the corpus links the words

Predict first

A query says "suitcase" but the only document that answers it says "luggage". Which method can still return that document?

Text description of the figure

A picker of 40 labelled queries (28 keyword, 12 paraphrase), or a typed query, over 567 documents from the academy's own glossary, insights and lesson text. Controls set k, the number of LSA dimensions (the dense representation here is LSA, not a neural model) and whether to show TF-IDF, LSA or both. Each method lists its top documents with cosine scores, the words shared with the query and whether each document is labelled relevant, then reports recall, precision, reciprocal rank and nDCG for the query and the mean over all labelled queries, with a bootstrap interval for the gap.

Four ideas to hold on to Overview
  • Count

    A message becomes a row of word counts; order is thrown away.

  • Weight

    TF-IDF damps words found everywhere and lifts rare, specific ones.

  • Densify

    SVD or an encoder places co-occurring words near each other.

  • Split by group

    Copies of one ticket on both sides of a split measure memory.

From message to intent Diagram
Messageraw textTokenslowercase, split,n-gramsCount matrixone column per word,sparseTF-IDF weightscount times rarityLogistic regressionreadable word weightsUnlabelled poolno labels neededSVD, k dimensionsLSA: co-occurring wordsmergefit onprojectoptional
  1. Messageraw text
  2. Tokenslowercase, split, n-grams
  3. Count matrixone column per word, sparse
    • to TF-IDF weights
  4. Unlabelled poolno labels needed
    • fit on: to SVD, k dimensions
  5. TF-IDF weightscount times rarity
    • to Logistic regression
  6. SVD, k dimensionsLSA: co-occurring words merge
  7. Logistic regressionreadable word weights

Every fitted step (vocabulary, IDF, SVD) is preprocessing and obeys the Day 9 boundary. The SVD reads no labels, so the unlabelled pool is free data for it.

A paraphrase is invisible to TF-IDF and obvious to LSA Illustration
TF-IDF cosinebaggagedelayrefundseat00.10.20.3top 5 returned →cosine with the query

TF-IDF scores a message only through shared words. No labelled baggage message contains carousel or belt, so they share only filler words with the query and sit among the other intents. Baggage in its top 5: 1.

LSA cosine (4 dimensions)baggagedelayrefundseat00.250.500.751.00top 5 returned →cosine with the query

LSA, fitted on the 600 unlabelled messages where carousel and belt co-occur with luggage, lifts all 20 baggage messages to the top without a single shared topic word. Baggage in its top 5: 5.

Synthetic inbox (seeded): query “the carousel belt again please”; 60 labelled messages (filled dots: baggage) written only with the older words; IDF and LSA fitted on the 600 unlabelled plus 60 labelled messages with the academy’s retrieval code.

One query in the newer words against 60 labelled messages written in the older ones. Same data, two representations; only LSA saw the co-occurrence.

Three text representations Comparison
CriterionTF-IDFlexical baselineLSAtruncated SVD of TF-IDFNeural encoderpretrained, docs-only here
What a message becomesSparse row, one weight per vocabulary wordk dense numbers per messageOne dense vector per message from a pretrained model
Links two synonymsNo: different words are unrelated columnsYes, if they co-occur in the fitting corpusOften, from the encoder's own training corpus
NeedsLabels that cover the phrasingAn unlabelled pool from the same inboxA model download, a version to pin, latency
Inspect and operateReadable weights; milliseconds; nothing to downloadDimensions are blends of words; choose k on validationOpaque; re-embed everything when the model changes

Run the left column first. Each step to the right must win on the same grouped split to earn its cost.

Which representation the situation calls for Map

TF-IDF is enough

Few labels, but the words repeat: the baseline already separates intents.

TF-IDF is hard to beat

Many labels covering the words: dense models rarely add much.

Dense helps most

Few labels, new words: LSA or an encoder can transfer weight.

Label the new words

Many labels, but drift: label recent messages, then compare.

A practitioner rule of thumb, to test on your own grouped split.

Same model, same messages: the split alone moves accuracy from 0.56 to 0.98 Chart
  • Random split of messages87% of test messages have a near-copy in training0.98 accuracy
  • Split grouped by ticket0% near-copies: the number production will see0.56 accuracy

Optional lab: 150 synthetic tickets, each resubmitted three times with small edits. Near-copy = TF-IDF cosine 0.8 or more to a training message. In the random split, 100 of the 104 test tickets also sit in training.

  • Pipeline: Tokenization -> counts -> TF-IDF weights -> logistic regression (Day 11).
  • Dense option: Latent semantic analysis (LSA) (truncated SVD of the TF-IDF table) or a neural encoder (Day 38).
  • Evaluation: split by ticket or template so near-duplicates cannot flatter the score.
  • Details are in the pictures above and the optional technical depth below.

Assumptions

  • Word presence is informative enough that discarding order and negation ("not delayed") costs little; routing usually passes, clause-level sentiment may not.

  • Labelled and future messages share most of their vocabulary; when they do not, dense representations or more labels are needed.

  • Near-duplicates are identifiable by a group key or a similarity check, so the split can respect them.

Sign in to record this block.

Application and workplace decision · 10 min

  • Data (synthetic): 60 labelled complaints, 600 unlabelled, four intents (baggage, delay, refund, seat).
  • Labelled messages use three phrasings per intent; this year's inbox adds two newer ones, which co-occur with the old ones in the unlabelled pool.
  • The code fits TF-IDF and an 8-dimensional LSA, trains one logistic regression on each and scores familiar and new phrasing; a second pass trains on labels that already include the new words.
  • Predict first: how does TF-IDF score on new phrasing?

Python

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(41)
topics = [  # synthetic complaint intents; the last two words of each are newer phrasings
    "luggage suitcase bag carousel belt".split(),
    "delayed late postponed waiting rebooked".split(),
    "refund reimbursement voucher compensation chargeback".split(),
    "seat upgrade legroom aisle window".split(),
]
filler = "the my on flight again very still please staff gate told after".split()

def make(n, lo, hi):  # topic words drawn from positions lo..hi of each intent
    y = rng.integers(0, 4, n)
    docs = [" ".join(rng.choice(topics[i][lo:hi], 2).tolist() + rng.choice(filler, 5).tolist()) for i in y]
    return docs, y

pool, _ = make(600, 0, 5)  # unlabelled history: all phrasings, no labels
old_x, old_y = make(300, 0, 3)  # test: familiar phrasing
new_x, new_y = make(300, 3, 5)  # test: phrasing absent from the labelled set
for vocab in (3, 5):  # labelled set uses only familiar words, or all words
    tr_x, tr_y = make(60, 0, vocab)
    tfidf = TfidfVectorizer().fit(pool + tr_x)
    svd = TruncatedSVD(8, random_state=0).fit(tfidf.transform(pool))
    for name, f in {"tfidf": tfidf.transform, "lsa": lambda d: svd.transform(tfidf.transform(d))}.items():
        clf = LogisticRegression(C=10, max_iter=1000).fit(f(tr_x), tr_y)
        print(f"train words<={vocab} {name:<5} familiar={clf.score(f(old_x), old_y):.2f} new={clf.score(f(new_x), new_y):.2f}")

This code is executed in the repository's checks (pnpm lesson:py / lesson:sql). It is not run on this site; copy it into your notebook.

Sign in to record this block.

Mini-assessment · 8 min

Your first answer is your evidence. You can re-answer later in practice mode; that is labelled practice.

Question 1 · easy

In TF-IDF weighting, what does the inverse document frequency factor do to a word such as "flight" that appears in almost every complaint?
Options

How confident are you? (optional, used for calibration)

Question 2 · medium

A TF-IDF plus logistic regression router was trained on 60 labelled messages that all mention "luggage". A new message reads: "my suitcase never came out on the belt". The words "suitcase" and "belt" never appear in the labelled set. What does the model do with them?
Options

How confident are you? (optional, used for calibration)

Question 3 · hard

A team reports 0.96 accuracy for a complaint-routing model on a random 80/20 split of 9,000 messages. The inbox contains resubmitted tickets (about three copies each, small edits). In the first week of production the same model scores 0.71. The team proposes a larger embedding model. What is the most likely cause, and what do you do first?
Options

How confident are you? (optional, used for calibration)

Question 4 · medium

You have 60 labelled complaints, 600 unlabelled messages from the same inbox and a test set of 300 messages that you will score once. You want TF-IDF followed by an 8-dimensional SVD so that new phrasings can inherit the weights of their neighbours. On which messages do you fit the vectorizer and the SVD, and on which do you train the logistic regression?
Options

How confident are you? (optional, used for calibration)

Question 5 · hard

A router must send "my flight is delayed again" to an agent and close "my flight is not delayed any more, thanks" automatically. It uses TfidfVectorizer(stop_words="english") with logistic regression, and on this pair of intents accuracy is barely above a coin flip. Error analysis shows that the two kinds of message differ mostly by the word "not". What is the best diagnosis and first change?
Options

How confident are you? (optional, used for calibration)

Sign in to complete lessons and keep your progress.

Technical depth · optional · 27 min in total

Open only what you need. Nothing here is required to complete the day; the assessment covers the core above.

TF-IDF, derived and computed by hand4 minThe exact weighting scikit-learn uses, pushed through a three-message corpus on paper.
TF-IDF as scikit-learn computes it (smooth_idf, the default) Formula
wt,d=tft,d⋅(ln⁡1+N1+dft+1)w_{t,d} = \mathrm{tf}_{t,d} \cdot \left(\ln\frac{1+N}{1+\mathrm{df}_t} + 1\right)
tft,d\mathrm{tf}_{t,d}
count of term t in message d
NN
number of messages the vectorizer was fitted on
dft\mathrm{df}_t
number of those messages that contain t
+1+1
added after the log, so a word in every message keeps weight 1; the 1+ inside the log is the smoothing

Each row is then scaled to unit length (L2), so cosine similarity is a plain dot product.

IDF weights on four tiny messages (synthetic card-support inbox) Chart
  • card (3 of 4 messages)1.223 idf
  • payment (3 of 4)1.223 idf
  • my (2 of 4)1.511 idf
  • refund (1 of 4)1.916 idf
  • loan (1 of 4)1.916 idf

Messages: my card payment failed; card charged twice for payment; refund my card fee; loan payment due date. Computed with TfidfVectorizer defaults.

TF-IDF by hand: d1 of three returns-desk messages (e-commerce, synthetic) Table
Termdf of 3 docsidf = ln(4/(1+df)) + 1raw = tf x idfafter L2
refund2 (d1, d2)ln(4/3) + 1 = 1.2881 x 1.288 = 1.2881.288 / 3.008 = 0.43
for1 (d1)ln(4/2) + 1 = 1.6931 x 1.693 = 1.6931.693 / 3.008 = 0.56
late1 (d1)1.6931.6930.56
delivery2 (d1, d3)ln(4/3) + 1 = 1.2881.2880.43
L2 norm of d1--sqrt(2 x 1.288^2 + 2 x 1.693^2) = 3.008divide each raw weight by it

d1 = "refund for late delivery", d2 = "damaged parcel refund", d3 = "update delivery address". Every number recomputed with TfidfVectorizer defaults (smooth_idf, L2): the fitted rows match to three decimals.

The fitted TF-IDF matrix: each message is a sparse unit row Grid
Vocabulary word →
Message ↓refundforlatedeliverydamagedparcelupdateaddress
d1 refund for late...0.430.560.560.430.000.000.000.00
d2 damaged parcel...0.470.000.000.000.620.620.000.00
d3 update delivery...0.000.000.000.470.000.000.620.62

All three rows from TfidfVectorizer defaults (computed); row d1 is the worked table above. Words in two messages (refund, delivery) weigh less than words in one. Most cells are 0: a message only has weight on its own words, which is why cosine needs overlap.

Fit on training text, transform test text: an unseen word simply vanishes Diagram
Training + pooltextunlabelled poolincluded;…fit: vocabulary +IDFone column per word seenhereTest messagescored once, atthe endtransformfitted columns onlyKnown wordcount x fitted IDFUnseen worddropped: no error, noweightfit onfixed columnstransform onlyweightedsilentlynever (leaks)
  1. Training + pool textunlabelled pool included; no labels needed
    • fit on: to fit: vocabulary + IDF
  2. Test messagescored once, at the end
    • transform only: to transform
  3. fit: vocabulary + IDFone column per word seen here
  4. transformfitted columns only
    • silently: to Unseen word
  5. Known wordcount x fitted IDF
  6. Unseen worddropped: no error, no weight

The Day 9 boundary applied to text. Refitting on test text would add test-only words to the vocabulary and IDF. The quiet failure is the other branch: the pipeline runs, and the new word contributes nothing to the row.

Defaults that matter (scikit-learn 1.9.1, checked in this repo) Table
SettingDefaultWhy an expert changes it
TfidfVectorizer token_patternwords of 2+ charactersSingle letters and digits vanish, so a code written as A 7 loses its parts
ngram_range(1, 1)Bigrams keep short phrases and negation as one column
sublinear_tfFalseTrue uses 1 + log tf, so a repeated word does not dominate
min_df / max_df1 / 1.0Prune one-off typos and near-universal words on large corpora
TruncatedSVD n_components2Always set it; choose k on a grouped validation split
LogisticRegression C, max_iter1.0, 100Tune C; raise max_iter on wide sparse data if it warns

Verified with get_params() on the installed versions. The application code sets C = 10 and k = 8 explicitly.

Cosine retrieval, by hand4 minThe retrieval score as a dot product of unit rows, computed on the same three messages, and where lexical scoring wins or loses.
Cosine similarity, the retrieval score Formula
cos⁡(q,d)=q⋅d∥q∥ ∥d∥\cos(q, d) = \frac{q \cdot d}{\lVert q \rVert\,\lVert d \rVert}
q⋅dq \cdot d
sum over shared vocabulary words of the two weights multiplied
∥q∥\lVert q \rVert
L2 length; already 1 for TfidfVectorizer rows
cos⁡=0\cos = 0
no shared word with nonzero weight

With L2-normalised rows the cosine is a plain dot product, so ranking all 600 messages against a query is one sparse matrix product, with no loop. That is why the vectorizer normalises by default.

Three queries against the three returns-desk messages: cosine needs a shared word Grid
Message →
Query ↓d1 late deliveryd2 damaged parceld3 address change
late delivery refund0.830.250.25
where is my parcel0.000.620.00
where is my package0.000.000.00

TfidfVectorizer fitted on the three messages (computed). Row 1: refund ties d2 and delivery ties d3. Row 2: one shared informative word is enough. Row 3: package was unseen at fit time, so it is dropped and every score is exactly 0, the vocabulary-mismatch failure the lesson turns on. A 0.00 is about shared words, not relevance.

Lexical versus dense, in retrieval Comparison
CriterionLexical (TF-IDF, BM25)Dense (LSA, encoders)
Strong onIdentifiers, codes, rare exact termsParaphrase, synonyms present in its corpus
Weak onParaphrase with no shared wordsRare identifiers with no semantic neighbours
Typical useBaseline; lexical half of hybrid search (Day 48)Semantic half of hybrid search

A practitioner heuristic; test it on your own labelled queries.

BM25, the lexical scorer behind most search engines Formula
s(q,d)=∑t∈qidft⋅ft,d (k1+1)ft,d+k1(1−b+b ∣d∣avgdl)s(q,d) = \sum_{t \in q} \mathrm{idf}_t \cdot \frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1\left(1 - b + b\,\frac{|d|}{\mathrm{avgdl}}\right)}
ft,df_{t,d}
count of term t in document d
k1k_1
saturation: with k1 = 1.5 and b = 0, counts 1, 2, 3, 5, 10 weigh 1.0, 1.43, 1.67, 1.92, 2.17
bb
length normalisation: 0 ignores length, 1 fully divides by relative length
∣d∣/avgdl|d| / \mathrm{avgdl}
document length relative to the corpus average

Robertson and Zaragoza 2009. The academy's retrieval code uses k1 = 1.5, b = 0.75; libraries differ, so record yours. Saturation numbers computed in Python.

LSA as a low-rank approximation, by handadvanced6 minThe best rank-k factorisation derived, pushed through five messages on paper, and why it lets a new word borrow the weight of an old one.
LSA is a rank-k approximation Formula
X≈UkΣkVk⊤X \approx U_k \Sigma_k V_k^{\top}
XX
TF-IDF matrix, messages by words
UkΣkU_k \Sigma_k
k numbers per message (the dense representation)
VkV_k
k directions over words; co-occurring words load on the same direction

TruncatedSVD does not center the data, so it works on the sparse matrix directly; applied to a TF-IDF matrix, scikit-learn documents it as LSA. A randomized truncated SVD costs roughly nonzeros x k, so k = 100 on a million messages is routine; a full SVD is not.

Why truncation is the right cut: the best rank-k approximation Formula
min⁡rank(Y)≤k∥X−Y∥F2=∥X−Xk∥F2=∑i>kσi2\min_{\mathrm{rank}(Y) \le k} \lVert X - Y \rVert_F^2 = \lVert X - X_k \rVert_F^2 = \sum_{i > k} \sigma_i^2
σ1≥σ2≥…\sigma_1 \ge \sigma_2 \ge \dots
singular values of X; their squares add up to the sum of all squared entries of X
Xk=UkΣkVk⊤X_k = U_k \Sigma_k V_k^{\top}
keep the k largest singular values and their vectors; drop the rest
∑i>kσi2\sum_{i>k} \sigma_i^2
the squared error you pay is exactly the dropped singular values squared (Eckart-Young theorem)
xVkx V_k
fold-in: a new message x (a TF-IDF row) is placed in the space by multiplying it by the word directions

No other rank-k matrix reconstructs X better in squared error. Small k forces co-occurring words onto shared directions, which is the whole trick.

LSA by hand: five returns-desk messages, rank 2 (e-commerce, synthetic) Table
QuantityValueWhat to check on paper
Messages (word counts)d1 parcel lost; d2 package lost; d3 lost parcel package; d4, d5 change addressX is 5 messages x 5 words, eleven 1s
Singular values2.414, 2.000, 1.000, 0.414, 0Squares add to 11, the squared entries of X
Direction 1 (v1)parcel 0.50, package 0.50, lost 0.71The delivery topic: parcel and package load equally
Direction 2 (v2)address 0.71, change 0.71The address topic, orthogonal to direction 1
Rank-2 error1.000 + 0.172 = 1.172Exactly the two dropped singular values squared
Row d1 of X2parcel 0.60, package 0.60, lost 0.85d1 never said package, yet now carries weight on it
cos(query package, doc parcel)raw 0.00, rank 2: 1.00Fold both in with V2: they land on direction 1
cos(query package, doc address)raw 0.00, rank 2: 0.00Words that never co-occur stay apart

Raw counts instead of TF-IDF keep the arithmetic hand-sized; checked with numpy.linalg.svd (signs of singular vectors are arbitrary). The 1.00 is the caveat in miniature: at rank 2 parcel and package become indistinguishable.

Why an unseen word inherits weight Sequence
  1. Co-occurrence

    In the pool, suitcase appears in messages that also say luggage.

  2. Shared direction

    Their columns of X are similar, so SVD maps them to the same directions.

  3. Classifier

    Logistic regression learns weights on directions, not on words.

  4. Transfer

    A test message with only suitcase lands where luggage messages did.

  5. Caveat

    Unrelated words that merely co-occur merge too: inspect nearest neighbours.

The number of LSA dimensions decides whether meaning transfers Illustration
0.000.250.500.751.00148162432Routing accuracy on new phrasingflat region k 3–12chance (4 intents)TF-IDF 0.32 (= LSA at full rank)k = 1: accuracy 0.21k = 2: accuracy 0.71k = 3: accuracy 0.99k = 4: accuracy 1.00k = 5: accuracy 1.00k = 6: accuracy 1.00k = 8: accuracy 1.00k = 10: accuracy 1.00k = 12: accuracy 0.98k = 16: accuracy 0.85k = 20: accuracy 0.73k = 24: accuracy 0.59k = 32: accuracy 0.32LSALSA dimensions kAccuracy on new phrasing

Too few dimensions merge the intents. From k = 3 to 12 the new words land on their old synonyms (accuracy 1.00 first reached at k = 4, the number of intents). At k = 32 the space is the full word table again and LSA falls back to the TF-IDF level (0.32).

0%25%50%75%100%148162432Energy kept by k dimensionsk = 1: 21% of the energyk = 2: 26% of the energyk = 3: 31% of the energyk = 4: 35% of the energyk = 5: 39% of the energyk = 6: 42% of the energyk = 8: 49% of the energyk = 10: 55% of the energyk = 12: 61% of the energyk = 16: 71% of the energyk = 20: 80% of the energyk = 24: 88% of the energyk = 32: 100% of the energyk = 4: 35% keptLSA dimensions kEnergy kept

Share of the TF-IDF table's squared norm (energy) the k dimensions keep. At k = 4 only 35% is kept, yet routing is already perfect: the useful structure is a handful of co-occurrence directions, the rest is filler noise.

Synthetic inbox (seeded): 600 unlabelled and 60 labelled messages (older words only), 150 test messages in the newer words; 32 distinct words, so k = 32 is full rank. 1-nearest-neighbour routing stands in for the classifier of the application code.

Computed routing accuracy on new phrasing for every k, next to the share of the matrix the k dimensions keep. In practice pick k on a grouped validation split and report this whole curve, not one value; a flat region is the safe choice.

Properties and failure modes: what a bag of words cannot see3 minUnigrams, bigrams, character n-grams and stop lists each decide what the model can see, and each blind spot has a diagnostic.
Tokenization choices and what they change Table
ChoiceHelps withCosts or breaks
Word unigramsFast, readable baselineNo order: not delayed equals delayed
Word bigramsKeeps short phrases such as not delayedMany more, sparser columns
Character n-gramsTypos, inflections, codes such as ZX104Large vocabulary; weights harder to read
Stop-word listRemoves filler wordsscikit-learn's English list contains not, no, never
Subword (BPE)Open vocabulary for neural models (Day 42)Token counts differ by language
A stop list can delete the label: two loan messages become the same row Table
Vectorizer settingColumns it keepsCan the two rows differ?
stop_words="english"approved, loanNo: identical rows, no model can separate them
No stop list (default)approved, is, loan, not, yourYes: not is its own column
No stop list, ngram_range=(1, 2)the five words + is approved, is not, loan is, not approved, your loanYes, and not approved is one column

"your loan is approved" against "your loan is not approved" (bank, synthetic), fitted with TfidfVectorizer (computed). scikit-learn's English stop list contains not, no and never, so the one word that carries the label is removed before any weighting.

Three blind spots of a bag of words, each with its own diagnostic Comparison
CriterionVocabulary mismatchOrder and negationSparsity
What you seeCosine exactly 0 between a query and its answer"loan approved" and "loan not approved" differ by one column at mostTens of thousands of columns for a few hundred labels
DiagnosticShare of test messages whose words are mostly unseen at fit timeRead errors grouped by the presence of negation wordsGap between train and grouped validation scores
First fixLabels that cover the new words, or LSA / an encoderPhrase features (bigrams), then a sequence modelRegularisation strength C; min_df pruning

Logistic regression copes with sparsity through regularisation; it cannot recover a column the tokenizer never made.

Near-duplicate leakage3 minHow resubmitted tickets inflate scores and the split that prevents it.
What a random split of resubmitted tickets puts in the test set Illustration
Random split of messagesnear-copy zone: 69% of test0.84%4%2%19%5%64%00.51cosine to the closest training message

Random split of messages: 98 of the 102 tickets in the test part also have a copy in training, and 69% of test messages have a near-copy there. A model can score well by remembering ticket codes.

Split grouped by ticketnear-copy zone: 0% of test0.837%48%11%00.51cosine to the closest training message

Split by ticket: no ticket crosses the boundary and 0% of test messages have a near-copy. What remains is shared topic and filler words, the situation production will look like.

Synthetic tickets (seeded), 150 tickets x 3 edited copies, as in the optional lab. Similarity is TF-IDF cosine with IDF from all messages; only the split differs between the panels. A TypeScript re-implementation with its own seed, so its shares differ from the Python lab output.

Cosine from each test message to its closest training message, for the two splits of the same 450 messages.

Splitting text without duplicate leakage Diagram
Messagestickets, forwards,templatesGroup keyavailable?Split by groupticket, customer ortemplateCluster near-copiescosine >= 0.8 forms agroupCheck test-to-trainreport share of testwith cosine above 0.8yesnoas group
  1. Messagestickets, forwards, templates
  2. Group key available?
    • yes: to Split by group
  3. Cluster near-copiescosine >= 0.8 forms a group
  4. Split by groupticket, customer or template
  5. Check test-to-trainreport share of test with cosine above 0.8

A group key keeps every copy of a ticket on one side. Without one, near-copies are clustered first so that each cluster acts as the group.

Neural sentence encoders, fairly comparedadvanced2 minThe protocol for testing a pretrained encoder against the baseline without flattering it.
Trying a neural encoder fairly (docs-only) Sequence
  1. Pin

    Record model name, version, embedding size and maximum input length.

  2. Embed once

    Embed labelled and unlabelled messages; cache the vectors.

  3. Same head

    Train the same logistic regression on the vectors.

  4. Same split

    Compare with a tuned TF-IDF baseline on identical grouped folds, with intervals.

  5. Price it

    Add latency, cost and re-embedding work before deciding.

Diagnosing errors and the order of work3 minWhich error kind points to which fix, from group key to decision.
Reading the baseline's errors before choosing a fix Diagram
Baseline errorsgrouped splitWords unseen atfit?LSA or encoderfit on unlabelled poolOrder ornegation?Phrase featuresfirstthen a sequence modelLabelsinconsistent?Relabel with a guideno model fixes noiseRe-score same splitwith an intervalyesnoyesnoyes
  1. Baseline errorsgrouped split
  2. Words unseen at fit?
    • yes: to LSA or encoder
  3. Order or negation?
    • yes: to Phrase features first
  4. Labels inconsistent?
    • yes: to Relabel with a guide
  5. LSA or encoderfit on unlabelled pool
    • to Re-score same split
  6. Phrase features firstthen a sequence model
    • to Re-score same split
  7. Relabel with a guideno model fixes noise
  8. Re-score same splitwith an interval

A practitioner diagnostic order. Each branch names a different cause, and only the first is a representation problem.

The order of work, from group key to decision Sequence
  1. Group key

    Choose ticket, customer or template before anything is fitted.

  2. Tuned baseline

    TF-IDF plus logistic regression, with a small search over n-grams and C.

  3. Read 20 errors

    Tag each one: unseen words, order or negation, or label noise (the diagram above).

  4. Dense candidate

    LSA or an encoder on the same grouped split, with the same head and metric.

  5. Price the decision

    Intervals on the grouped score and cost per routed message decide; novelty does not.

Interview nuances and common misreadingsadvanced2 minWhat separates a staff-level answer about text baselines from a textbook one.
What a staff-level answer adds Overview
  • Split before model

    Names the group key and the leakage check before any model is mentioned.

  • Errors

    Reads the top weights and 20 errors to name the failure kind.

  • Corpus

    Says a dense space only links words its corpus put together.

  • Total cost

    Prices latency, re-embedding on model change and a pinned version.

  • Misreading

    LSA dimensions are not named topics; they are blends of words that change with k.

Memory anchor

Baseline first on a grouped split; a dense representation earns its place only where the words differ.

Useful for

  • Setting a text baseline in an afternoon, with readable weights, before any language model is considered (an e-commerce returns inbox, a bank's dispute notes).

  • Routing, tagging and deduplicating messages (insurance claim descriptions, telecom fault reports), or building the lexical half of a hybrid search (Day 48).

  • Diagnosing whether a failure is vocabulary mismatch (a representation problem) or label scarcity (a data problem).

Not suitable for

  • Tasks where word order or negation decides the label (a credit-card dispute that is "not authorised" versus "authorised", contradiction in clinical notes); a bag of words cannot separate them.

  • Treating LSA, or any embedding, as proof of understanding; similarity in the space is a corpus statistic, not an explanation.

Traps

Scoring on a random split of duplicated text

Resubmitted tickets, forwarded threads and template replies (an insurer's claim correspondence is full of them) put copies of one message on both sides of the split. The model is rewarded for remembering ticket-specific words, so offline accuracy is high and production accuracy is not.

How to avoid it

Split by a group key (ticket, customer, template) before fitting anything, report the nearest-neighbour similarity between test and train, and deduplicate near-copies when the group key is unavailable.

Comparing a tuned dense model with an untuned lexical baseline

In a product-review sentiment study the baseline is run with default settings and a stray stop-word list, the encoder gets a tuned head, and the encoder "wins". The difference is effort, not representation.

How to avoid it

Give the baseline the same split, a small search over n-grams and regularisation, and the same metric, then compare on identical grouped folds.

Senior reasoning

A senior treats the representation as a hypothesis about what separates the classes, whether the text is a telecom fault report, a patient-portal message or a bank's dispute note. They run the lexical baseline first, read its largest weights, and look at the errors: if they are unseen phrasings, a dense representation or more labels is justified; if they are label noise or duplicates, a bigger model will not help. They report the baseline and the candidate on the same grouped split (by ticket, patient or account), with intervals, and they price the extra model's latency, versioning and failure modes before adopting it.

Interview question

A clinic's patient portal receives free-text messages, and your team proposes a sentence-embedding model to route them into 12 triage categories. How do you decide whether it is worth it over a lexical baseline?

Recall cards scheduled after completion

  • What does TF-IDF up-weight and down-weight, and what can it not do?
  • Why can a random split of customer messages overstate accuracy, and what replaces it?

Reviewed on a 1 · 3 · 7 · 14 · 30-day schedule; a miss returns the card to 1 day.

References

Optional sections

Deep dive · 10 min

Why SVD finds synonyms. Two words that occur in the same messages have similar columns in XX. The best rank-kk approximation keeps the directions that explain most of the variation, and similar columns project onto the same directions, so a message containing "suitcase" and one containing "luggage" end up close in the kk-dimensional space even if they share no word. The same mechanism merges unrelated words that merely co-occur (a product brand and a seasonal discount code), which is why you inspect the nearest neighbours of a few terms before trusting the space.

Choosing kk. Too small and distinct intents collapse; too large and the space reproduces the sparse table. In the application code, k=8k=8 for four intents leaves room for filler structure. In practice, pick kk by validation on a grouped split and report the sensitivity.

Lexical versus dense in retrieval. For queries that contain exact identifiers (SKU numbers, error codes, a policy clause number), a lexical method is often better than a dense one, because identifiers are rare tokens with high IDF and no semantic neighbours (a practitioner heuristic, to be tested on your queries). Dense methods help for paraphrase. Day 48 combines both. BM25 (Robertson and Zaragoza, 2009) is a lexical scoring function widely used in search engines as a refinement of plain TF-IDF.

Neural embeddings (docs-only). A pretrained sentence encoder returns one vector per message. Typical use: embed the 60 labelled and all unlabelled messages once, train logistic regression on the vectors, and compare with the TF-IDF baseline on the same grouped split. Pin the model name and version, record the embedding dimension and the maximum input length, and re-embed when any of them changes. The course runs no model downloads; the pattern is described in the Sentence-BERT paper and the SentenceTransformers documentation.

Retrieval project link (weekend studio). Saturday's retrieval milestone applies today's baseline to the academy's own text: TF-IDF vectors with cosine similarity over chunks of lessons, glossary and insights. Chunk ids that keep the source record in the id let you split or score by record, which is the grouped-split idea applied to retrieval, and the printed top-1 score on unanswerable queries is the lexical analogue of the nearest-neighbour check.

Hands-on lab: Measure the damage a random split does with near-duplicate tickets · 15 min

Run the code and read the three numbers: accuracy on a random split, accuracy on a split grouped by ticket, and the share of test messages that have a training neighbour above 0.8 cosine similarity. Then change the probability of dropping a word from 0.1 to 0.4 and rerun: how does the gap move, and why? Finally, add a near-duplicate filter that removes training messages above 0.8 similarity to any test message, and compare it with the grouped split.

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GroupShuffleSplit, train_test_split

rng = np.random.default_rng(7)
topics = ["luggage suitcase bag".split(), "delayed late waiting".split(),
          "refund voucher compensation".split(), "seat upgrade legroom".split()]
filler = "the my on flight again very still please staff gate told after".split()

# 150 synthetic tickets; each is re-submitted three times with small edits (near-duplicates)
docs, labels, groups = [], [], []
for t in range(150):
    y = int(rng.integers(0, 4))
    topic_word = rng.choice(topics[y] if rng.random() < 0.6 else topics[int(rng.integers(0, 4))])
    base = [topic_word] + rng.choice(filler, 4).tolist() + [f"zx{rng.integers(100, 999)}", f"pnr{rng.integers(1000, 9999)}"]
    for _ in range(3):
        words = [w for w in base if rng.random() > 0.1]  # copy with ~10% of words dropped
        if rng.random() < 0.5:
            words.append("please")
        docs.append(" ".join(words))
        labels.append(y)
        groups.append(t)
docs, labels, groups = np.array(docs), np.array(labels), np.array(groups)

def evaluate(train_idx, test_idx, name):
    vec = TfidfVectorizer().fit(docs[train_idx])
    xtr, xte = vec.transform(docs[train_idx]), vec.transform(docs[test_idx])
    acc = LogisticRegression(C=10, max_iter=1000).fit(xtr, labels[train_idx]).score(xte, labels[test_idx])
    nn = (xte @ xtr.T).max(axis=1).toarray().ravel()  # cosine to nearest training message (rows are L2-normalised)
    print(f"{name:<14} accuracy={acc:.2f}  test msgs with a train neighbour >= 0.8: {(nn >= 0.8).mean():.0%}")

idx = np.arange(len(docs))
r_tr, r_te = train_test_split(idx, test_size=0.3, random_state=0)
evaluate(r_tr, r_te, "random split")
g_tr, g_te = next(GroupShuffleSplit(1, test_size=0.3, random_state=0).split(idx, groups=groups))
evaluate(g_tr, g_te, "grouped split")
print("shared tickets across random split:", len(set(groups[r_tr]) & set(groups[r_te])), "of", len(set(groups[r_te])))
print("shared tickets across grouped split:", len(set(groups[g_tr]) & set(groups[g_te])))
Interview practice · 10 min

A colleague shows 0.97 accuracy for a TF-IDF classifier that routes an insurer's claim emails and wants to ship it. The data contain forwarded email threads. What do you ask for before you agree?

Content version 7 · last verified 2026-10-18 · depends on scikit-learn, numpy