The Standard Language Tasks

Week 5 of 14 · Language · 7 days

Full curriculum
Week 05 · Language

The Standard Language Tasks

Week 05 · Day 1 of 7

The Eight Standard Tasks

What language work actually consists of, and which half can be measured

By 218 words

Week 4 built one classifier. Most language work in industry is one of a handful of standard tasks, and knowing the list tells you what to search for and what a reasonable result looks like.

TaskQuestionOutput
ClassificationWhat kind of document is thisOne label per document
SentimentWhat attitude does it expressA label or a score
Named entity recognitionWhich spans are people, places, datesLabelled spans
Keyword extractionWhat is this aboutA few terms
Topic modellingWhat themes run through the collectionGroups, unsupervised
Similarity and searchWhich documents resemble this oneA ranking
SummarisationWhat does this say, brieflyNew text
TranslationWhat does this say in another languageNew text

The first six are solvable to a useful standard with the methods in this week. The last two produce new text, which is a different kind of problem and is where week 7's models are genuinely required rather than merely fashionable.

The dividing line worth noticing

Tasks that pick from existing options can be measured against a known answer, so you can tell whether they work. Tasks that generate text have no single right answer, which makes them much harder to evaluate and much easier to be fooled by. That distinction matters more than the apparent difficulty of the task.

Week 05 · Day 2 of 7

Finding the Things in the Text

Entity extraction with rules, and the ambiguity rules cannot settle

By 542 words

Finding the people, places, dates and amounts in a document is one of the most commercially useful of these tasks, because it turns prose into rows in a table.

Named entity recognition: Locating spans of text that refer to particular things and labelling what kind of thing each is. Unlike classification, the output is a set of positions rather than one answer per document.

Rules first, as always

DOCS = [
'Acme Ltd shipped the order from Manchester on 3 March 2024 for 249.99',
'Dr Patel at Riverside Clinic referred the patient on 12 June 2023',
'Contact Jane Whitfield at jane@northgate.co.uk or 0161 496 0000',
'Northgate Systems reported revenue of 1.4m for the quarter',
'The invoice from Boyd and Sons is dated 7 January 2025',
]
import re

PATTERNS = [
('DATE', r'\b\d{1,2} (?:January|February|March|April|May|June|July|'
r'August|September|October|November|December) \d{4}\b'),
('MONEY', r'\b\d+(?:\.\d{2})?m?\b(?= for| revenue|$)'),
('EMAIL', r'\b[\w.]+@[\w.]+\b'),
('PHONE', r'\b0\d{3} \d{3} \d{4}\b'),
('ORG', r'\b[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)* '
r'(?:Ltd|Systems|Clinic|and Sons)\b'),
]

for doc in DOCS:
found = []
for label, pattern in PATTERNS:
for m in re.finditer(pattern, doc):
found.append('%s=%s' % (label, m.group(0)))
print(doc)
print(' %s' % (found or 'nothing found'))
Acme Ltd shipped the order from Manchester on 3 March 2024 for 249.99
['DATE=3 March 2024', 'MONEY=2024', 'MONEY=249.99', 'ORG=Acme Ltd']
Dr Patel at Riverside Clinic referred the patient on 12 June 2023
['DATE=12 June 2023', 'MONEY=2023', 'ORG=Riverside Clinic']
Contact Jane Whitfield at jane@northgate.co.uk or 0161 496 0000
['MONEY=0000', 'EMAIL=jane@northgate.co.uk', 'PHONE=0161 496 0000']
Northgate Systems reported revenue of 1.4m for the quarter
['MONEY=4m', 'ORG=Northgate Systems']
The invoice from Boyd and Sons is dated 7 January 2025
['DATE=7 January 2025', 'MONEY=2025', 'ORG=Boyd and Sons']

Dates, emails and phone numbers are genuinely well suited to rules: they have a defined format, the format rarely changes, and a regular expression is exact. This is week 3's lesson arriving in a language context.

Where rules stop

DOCS = [
'Acme Ltd shipped the order from Manchester on 3 March 2024 for 249.99',
'Dr Patel at Riverside Clinic referred the patient on 12 June 2023',
'Contact Jane Whitfield at jane@northgate.co.uk or 0161 496 0000',
'Northgate Systems reported revenue of 1.4m for the quarter',
'The invoice from Boyd and Sons is dated 7 January 2025',
]
import re

ORG = re.compile(r'\b[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)* '
r'(?:Ltd|Systems|Clinic|and Sons)\b')
hard = [
'Apple sold more units than expected',
'I ate an apple at Apple headquarters',
'Washington signed the order in Washington',
'The Manchester office called Manchester United',
]
for text in hard:
print('%-46s %s' % (text, ORG.findall(text) or []))
print()
print('the suffix rule finds nothing, and the ambiguity is the point')
Apple sold more units than expected []
I ate an apple at Apple headquarters []
Washington signed the order in Washington []
The Manchester office called Manchester United []

the suffix rule finds nothing, and the ambiguity is the point

Apple is a company or a fruit depending on the sentence. Washington is a person, a city or a state. No list of suffixes settles these, because the evidence is the surrounding words, and using surrounding words as evidence is exactly what a learned model does.

The practical answer is usually both

Use rules for the things with a fixed format: dates, references, postcodes, amounts. Use a learned model for the things that depend on context: people, organisations, products. Systems that try to do either half with the wrong tool are common and both failure modes are expensive.

Week 05 · Day 3 of 7

What Is This About

Keywords from TF-IDF, and themes with nothing to score against

By 994 words

Deciding what a document is about, without being given any categories, is a different shape of problem. Nobody supplies labels, so nothing can be scored against an answer key, and that changes how you work.

Keywords by weight

import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np

vec = TfidfVectorizer(ngram_range=(1, 2), min_df=2)
X = vec.fit_transform(texts)
words = vec.get_feature_names_out()
for i in [0, 1, 2]:
row = X[i].toarray()[0]
top = np.argsort(row)[::-1][:4]
print('%s' % texts[i])
print(' -> %s' % ', '.join(words[j] for j in top if row[j] > 0))
after a month the keyboard is still quite reliable, as described in the listing
-> quite reliable, reliable as, still quite, keyboard is
for the price the charger is not sturdy, second one i have bought
-> not sturdy, sturdy second, charger is, charger
for the price the lid is hardly scratched, as described in the listing
-> hardly scratched, scratched as, lid is, the lid

TF-IDF gives keyword extraction almost for free: the highest weighted terms in a document are, by construction, the ones that are frequent here and rare elsewhere, which is a workable definition of what something is about.

Themes across a whole collection

import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import NMF
import numpy as np

vec = TfidfVectorizer(min_df=3, stop_words='english')
X = vec.fit_transform(texts)
words = vec.get_feature_names_out()
model = NMF(n_components=3, random_state=0, max_iter=400).fit(X)
for k, comp in enumerate(model.components_):
top = np.argsort(comp)[::-1][:6]
print('theme %d: %s' % (k + 1, ', '.join(words[i] for i in top)))
theme 1: tuesday, ordered, packaging, fine, listing, described
theme 2: week, took, delivery, like, wanted, far
theme 3: second, bought, far, price, month, wanted

Nobody told it there were three product categories. It found groups of words that tend to occur together, and those groups correspond to the categories because that is the strongest structure in the text.

Topic models are suggestive, not conclusive

The number of themes is chosen by you, the result changes if you change it, and there is no accuracy to check it against. What comes out is a reading aid: it tells you what to go and look at. Treating the themes as though they were discovered facts about the collection is how topic modelling gets misused.

Week 05 · Day 4 of 7

Similarity and Search

Cosine similarity, and the paraphrase that scores lower than its opposite

By 694 words

Similarity underpins search, deduplication, recommendation and retrieval. With documents already represented as vectors, it is one operation.

Cosine similarity: The angle between two document vectors, ignoring their lengths. Two documents about the same thing point in the same direction whether one is a sentence and the other a page, which is why angle is used rather than distance.
import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

vec = TfidfVectorizer(ngram_range=(1, 2))
X = vec.fit_transform(texts)
query = 'the battery is not reliable'
sims = cosine_similarity(vec.transform([query]), X)[0]
print('query: %s' % query)
for i in np.argsort(sims)[::-1][:4]:
print(' %.3f %s' % (sims[i], texts[i]))
query: the battery is not reliable
0.715 for the price the battery is not reliable, the packaging was fine
0.501 the battery is not scratched, delivery took a week
0.482 the battery is not excellent, second one i have bought
0.465 the kettle is not reliable, delivery took a week

What word matching cannot do

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

pairs = [
('the film was excellent', 'the movie was superb'),
('the film was excellent', 'the film was terrible'),
]
vec = TfidfVectorizer().fit([s for pair in pairs for s in pair])
for a, b in pairs:
sim = cosine_similarity(vec.transform([a]), vec.transform([b]))[0][0]
print('%.3f %-28s %s' % (sim, a, b))
print()
print('two sentences that mean the same thing score lower than')
print('two that mean opposite things, because words are all it sees')
0.272 the film was excellent the movie was superb
0.543 the film was excellent the film was terrible

two sentences that mean the same thing score lower than
two that mean opposite things, because words are all it sees

That result is the clearest possible statement of the limit. Film and movie are different columns, so a paraphrase shares almost nothing. Excellent and terrible are also different columns, but the surrounding words are identical, so the opposite pair scores higher.

The gap week 6 exists to fill

Every method so far treats words as arbitrary distinct symbols with no relationships between them. Meaning is invisible to all of it. The next week is about representations in which film and movie are close together, which is the single idea that made modern language processing possible.
Week 05 · Day 5 of 7

Sentiment, and Why It Is Harder Than It Looks

Negation, sarcasm, mixed opinion, and the domain that inverts the meaning

By 648 words

Sentiment deserves its own day because it is the task most often deployed carelessly, and because its failure modes are instructive.

import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

model = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)),
LogisticRegression(max_iter=2000))
model.fit(train_x, train_s)

tricky = [
'the handle is not flimsy',
'the handle is flimsy',
'well that was money well spent',
'the screen is fine i suppose',
'the blade is sharp but the handle is loose',
]
for text in tricky:
p_pos = model.predict_proba([text])[0][1]
print('%.3f %s' % (p_pos, text))
0.743 the handle is not flimsy
0.456 the handle is flimsy
0.500 well that was money well spent
0.459 the screen is fine i suppose
0.405 the blade is sharp but the handle is loose

The first two are handled, which is what week 4's bigrams bought. The rest are the standard hard cases and the model has no way to reach them: sarcasm inverts the meaning of positive words, faint praise sits between the classes, and a sentence with one positive and one negative clause has no single answer at all.

The problem with one label per document

  • Mixed opinions. A review praising the blade and criticising the handle is not positive or negative, and forcing it into one is discarding the useful information.
  • Aspect based sentiment is the version of the task that predicts an opinion per feature rather than per document, and it is usually what the business actually wanted.
  • Sarcasm and understatement require knowing what is normal, which is context no sentence contains.
  • Domain shift. A model trained on product reviews reads clinical notes badly, because positive means the opposite there.

The last one is not a joke

In a medical context a positive result is usually bad news. A sentiment model moved between domains without retraining will get this exactly backwards while remaining entirely confident, and it is a good illustration that these models learn the vocabulary of a particular world rather than the meaning of words.

Week 05 · Day 6 of 7

Measuring Each Task Properly

Why accuracy is wrong for spans, and what to do without ground truth

By 646 words

Every task in this week needs a different measurement, and using the wrong one is how projects report success and deliver nothing.

import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import classification_report, confusion_matrix

model = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)),
LogisticRegression(max_iter=2000))
model.fit(train_x, train_c)
pred = model.predict(test_x)
print(classification_report(test_c, pred))
precision recall f1-score support

clothing 1.00 1.00 1.00 244
kitchen 1.00 1.00 1.00 239
laptop 1.00 1.00 1.00 237

accuracy 1.00 720
macro avg 1.00 1.00 1.00 720
weighted avg 1.00 1.00 1.00 720

Measuring entity extraction

true = {('Acme Ltd', 'ORG'), ('Manchester', 'LOC'),
('3 March 2024', 'DATE')}
found = {('Acme Ltd', 'ORG'), ('3 March 2024', 'DATE'),
('order', 'ORG')}

hit = true & found
precision = len(hit) / len(found)
recall = len(hit) / len(true)
f1 = 2 * precision * recall / (precision + recall)
print('correct spans %s' % sorted(hit))
print('missed %s' % sorted(true - found))
print('invented %s' % sorted(found - true))
print()
print('precision %.3f recall %.3f f1 %.3f' % (precision, recall, f1))
correct spans [('3 March 2024', 'DATE'), ('Acme Ltd', 'ORG')]
missed [('Manchester', 'LOC')]
invented [('order', 'ORG')]

precision 0.667 recall 0.667 f1 0.667

Accuracy is meaningless here, because there is no fixed number of decisions being made. The measurement has to be over spans: how many of the ones you found were right, and how many of the real ones did you find. Note also that a span found with the wrong label counts as both a miss and an invention.

Measuring the unsupervised tasks

  • Topics have no ground truth. Judge them by whether a person can name each theme, and by whether documents assigned to a theme belong together.
  • Search and ranking are measured by where the good results land, not by whether they appear at all. A relevant document at position fifty is a failure.
  • Summarisation is measured badly by every automatic metric available, all of which compare word overlap with a reference summary and none of which detect a fluent summary that says something untrue.
Week 05 · Day 7 of 7

One Document, Several Tasks

A realistic pipeline built from four different techniques

By 705 words

One document, run through several of the week's tasks, which is what a real pipeline looks like.

DOCS = [
'Acme Ltd shipped the order from Manchester on 3 March 2024 for 249.99',
'Dr Patel at Riverside Clinic referred the patient on 12 June 2023',
'Contact Jane Whitfield at jane@northgate.co.uk or 0161 496 0000',
'Northgate Systems reported revenue of 1.4m for the quarter',
'The invoice from Boyd and Sons is dated 7 January 2025',
]
import random

random.seed(3)

CATEGORY_WORDS = {
'laptop': ['screen', 'keyboard', 'battery', 'trackpad', 'charger'],
'kitchen': ['pan', 'lid', 'handle', 'blade', 'kettle'],
'clothing': ['stitching', 'zip', 'collar', 'sleeve', 'fabric'],
}
GOOD = ['excellent', 'solid', 'reliable', 'sturdy', 'sharp']
BAD = ['flimsy', 'loose', 'faulty', 'scratched', 'noisy']
# The same intensifiers appear on both sides, so a model that keys on
# 'very' alone learns nothing at all.
DEGREE = ['very', 'quite', 'surprisingly', 'really']
NEGATE = ['not', 'far from', 'hardly']
FRAME = [
'the {part} is {opinion}, {tail}',
'arrived quickly and the {part} was {opinion}, {tail}',
'i wanted to like it but the {part} is {opinion}, {tail}',
'after a month the {part} is still {opinion}, {tail}',
'for the price the {part} is {opinion}, {tail}',
]
# One pool of closing phrases for both sentiments. An earlier version of
# this corpus gave each sentiment its own closers, which handed the
# label to anything that could read the last three words, and every
# model scored a perfect 1.000.
TAIL = ['ordered on tuesday', 'the packaging was fine',
'second one i have bought', 'delivery took a week',
'as described in the listing']

def make_review(category, positive):
part = random.choice(CATEGORY_WORDS[category])
# Half the positive reviews are written as a negated negative, so
# 'not flimsy' has to be read as praise. Bag of words cannot.
if positive:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(BAD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(GOOD))
tail = random.choice(TAIL)
else:
if random.random() < 0.5:
opinion = '%s %s' % (random.choice(NEGATE), random.choice(GOOD))
else:
opinion = '%s %s' % (random.choice(DEGREE), random.choice(BAD))
tail = random.choice(TAIL)
return random.choice(FRAME).format(part=part, opinion=opinion,
tail=tail)

texts, sentiment, category = [], [], []
for _ in range(400):
for cat in CATEGORY_WORDS:
for pos in (True, False):
texts.append(make_review(cat, pos))
sentiment.append(1 if pos else 0)
category.append(cat)

from sklearn.model_selection import train_test_split
(train_x, test_x, train_s, test_s,
train_c, test_c) = train_test_split(texts, sentiment, category,
test_size=0.3, random_state=0,
stratify=sentiment)
import re
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics.pairwise import cosine_similarity

sent = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)),
LogisticRegression(max_iter=2000)).fit(train_x,
train_s)
cat = make_pipeline(TfidfVectorizer(ngram_range=(1, 2)),
LogisticRegression(max_iter=2000)).fit(train_x,
train_c)
vec = TfidfVectorizer(ngram_range=(1, 2)).fit(texts)
X = vec.transform(texts)

incoming = ('the trackpad is not faulty but the battery is quite '
'noisy, ordered on 3 March 2024')
print('incoming: %s' % incoming)
print()
print('category %s' % cat.predict([incoming])[0])
print('sentiment %.3f positive' % sent.predict_proba([incoming])[0][1])
dates = re.findall(r'\b\d{1,2} [A-Z][a-z]+ \d{4}\b', incoming)
print('dates %s' % dates)
row = vec.transform([incoming])
words = vec.get_feature_names_out()
top = np.argsort(row.toarray()[0])[::-1][:3]
print('keywords %s' % ', '.join(words[i] for i in top))
sims = cosine_similarity(row, X)[0]
print('closest %s' % texts[int(np.argmax(sims))])
incoming: the trackpad is not faulty but the battery is quite noisy, ordered on 3 March 2024

category laptop
sentiment 0.586 positive
dates ['3 March 2024']
keywords quite noisy, not faulty, noisy ordered
closest the battery is quite noisy, ordered on tuesday

Four different techniques, each suited to its part of the job: a learned classifier for category, another for sentiment, a regular expression for the date, and vector similarity for retrieval. Nothing here needed a large model, and each piece can be measured on its own.

The checklist

  1. Name the task before choosing a method. Most language work is one of eight standard shapes.
  2. Use rules for anything with a fixed format, and learning for anything that depends on context.
  3. For unsupervised tasks, accept that there is no score and design human review into the process.
  4. Measure spans with precision and recall, not accuracy.
  5. Check the hard cases by hand: negation, mixed opinion, sarcasm. The aggregate number hides all of them.
  6. Before deploying anywhere new, test on text from that domain. Vocabulary does not transfer as well as it appears to.