Turning Text Into Numbers
Tokens, vocabularies, sparse matrices and the information you throw away
Every model so far has been handed numbers. Text is not numbers, and the whole of this week is about the step in between, because that step, not the choice of classifier, is what decides whether the model works.
The corpus
The broadband company whose customers you have been predicting for twelve weeks has a support inbox. This is it: 4,200 tickets, each with a free-text body, a category, and a flag for whether the customer is describing a problem that is now over. Generate it the same way you generated customers.csv in week 1.
import os
import numpy as np
import pandas as pd
SEED = 13
N = 4200
THING = ["router", "connection", "line", "broadband", "wifi", "signal",
"hub", "internet"]
# The two halves of the construction. A positive state word with no negation
# means the problem is over; the same word negated means it is not.
GOOD_STATE = ["working", "stable", "fixed", "fine", "sorted", "reliable",
"back to normal"]
BAD_STATE = ["slow", "down", "unstable", "broken", "patchy", "unreliable",
"dropping out"]
# Every one of these appears equally often on both sides of the label.
MODIFIER = ["", "still ", "not ", "no longer "]
NEGATING = {"not ", "no longer "} # these flip the state word
FRAME = [
"the {thing} is {mod}{state}",
"my {thing} is {mod}{state}",
"it is {mod}{state}",
"the {thing} has been {mod}{state} {when}",
"as of this morning the {thing} is {mod}{state}",
"{when} the {thing} is {mod}{state}",
]
WHEN = ["every evening", "since tuesday", "all weekend", "for three weeks",
"in the mornings", "since the storm", "most nights"]
# Context sentences carry the category and nothing else -- no resolution
# information at all, so the two labels stay genuinely different tasks.
CONTEXT = {
"technical": [
"the engineer visited on tuesday",
"this is the third time i have written about the hub",
"i have restarted the equipment twice",
"the speed test reads well under what i pay for",
"there is a flashing red light on the box",
],
"billing": [
"this relates to invoice 40182",
"the account is on the fibre tariff",
"i pay by direct debit on the eighth",
"there is a charge on the statement i do not recognise",
"the promotional discount was supposed to run for a year",
],
"cancellation": [
"i have been a customer for six years",
"i am considering my options",
"my contract ends next month",
"a competitor has offered me a better package",
"i would like to know the termination fee",
],
}
GREETING = ["", "", "", "hi team, ", "hello, ", "hi, ", "morning, ", "hi there "]
SIGNOFF = ["", "", "", "", " thanks", " please advise", " regards", " ta",
" please call me back"]
TYPOS = {"the": "teh", "and": "adn", "connection": "conection",
"internet": "internt", "please": "plese", "invoice": "invoce"}
def _core(rng, resolved):
"""One clause whose meaning comes from the modifier-plus-state pairing.
Pick the modifier first and independently of the label, then choose the
state word that makes the clause mean what the label says. That ordering
is what keeps every modifier balanced across both classes -- generate it
the other way round and the modifier leaks the answer to a unigram model.
"""
mod = rng.choice(MODIFIER)
good_state = resolved if mod not in NEGATING else not resolved
state = rng.choice(GOOD_STATE if good_state else BAD_STATE)
return rng.choice(FRAME).format(thing=rng.choice(THING), mod=mod,
state=state, when=rng.choice(WHEN))
def _rough(rng, text):
"""Put the mess back in: casing, punctuation, typos."""
if rng.random() < 0.10:
words = text.split()
for i, w in enumerate(words):
if w in TYPOS and rng.random() < 0.5:
words[i] = TYPOS[w]
text = " ".join(words)
if rng.random() < 0.12:
text = text.upper()
elif rng.random() < 0.25:
text = text.capitalize()
text = rng.choice(GREETING) + text + rng.choice(SIGNOFF)
text += rng.choice([".", ".", "", "!", "!!", "...", " ."])
if rng.random() < 0.08:
text = " " + text + " "
return text
def build(seed=SEED, n=N):
rng = np.random.default_rng(seed)
cats = rng.choice(["technical", "billing", "cancellation"], size=n,
p=[0.52, 0.33, 0.15])
rows = []
for i, cat in enumerate(cats):
resolved = int(rng.random() < 0.42)
parts = [_core(rng, resolved)]
# A second clause of the same polarity. Mixed polarity would leave the
# ticket with no correct answer, so it never happens.
if rng.random() < 0.30:
parts.append(_core(rng, resolved))
# The category sentence, which says nothing about resolution.
if rng.random() < 0.75:
where = rng.integers(0, len(parts) + 1)
parts.insert(int(where), rng.choice(CONTEXT[cat]))
text = _rough(rng, ". ".join(parts))
rows.append({"ticket_id": "T%05d" % (i + 1), "text": text,
"category": cat, "resolved": resolved})
df = pd.DataFrame(rows)
# ~4% label noise, so a perfect score is impossible and anybody who gets
# one goes looking for the leak instead of celebrating.
flip = rng.random(len(df)) < 0.04
df.loc[flip, "resolved"] = 1 - df.loc[flip, "resolved"]
# a few empty and near-empty tickets
blank = rng.choice(len(df), size=18, replace=False)
df.loc[blank[:9], "text"] = ""
df.loc[blank[9:], "text"] = rng.choice(["?", "...", "help", "hi", "!!"],
size=len(blank) - 9)
# double submissions
dupes = df.iloc[rng.choice(len(df), size=90, replace=False)]
df = pd.concat([df, dupes], ignore_index=True)
df = df.iloc[rng.permutation(len(df))].reset_index(drop=True)
return df
build().to_csv('tickets.csv', index=False, encoding='utf-8')
import pandas as pd
tickets = pd.read_csv('tickets.csv')
tickets['text'] = tickets['text'].fillna('').str.strip()
tickets = tickets.drop_duplicates(subset='ticket_id').reset_index(drop=True)
print('tickets %d' % len(tickets))
print(tickets[['ticket_id', 'category', 'resolved']].head())
print('\nresolved rate %.3f' % tickets['resolved'].mean())
print('categories:')
print(tickets['category'].value_counts())
ticket_id category resolved
0 T02210 billing 0
1 T00107 billing 1
2 T00680 billing 1
3 T03436 cancellation 0
4 T02257 billing 1
resolved rate 0.425
categories:
category
technical 2144
billing 1429
cancellation 627
Name: count, dtype: int64
import pandas as pd
tickets = pd.read_csv('tickets.csv')
tickets['text'] = tickets['text'].fillna('').str.strip()
tickets = tickets.drop_duplicates(subset='ticket_id').reset_index(drop=True)
for _, r in tickets.head(6).iterrows():
print('[%-12s resolved=%d] %s'
% (r['category'], r['resolved'], r['text'][:78]))
[billing resolved=1] hello, my hub is not unstable. my wifi is still fixed.
[billing resolved=1] hello, There is a charge on the statement i do not recognise. it is working. t
[cancellation resolved=0] the hub is down please advise
[billing resolved=1] hi, the account is on the fibre tariff. as of this morning the router is no lo
[billing resolved=0] in the mornings the wifi is unstable please advise .
Look at the raw strings before you clean anything
Shouting in capitals, doubled exclamation marks, leading spaces, typos, and nine tickets that are empty. Every one of those is in the file on purpose, because every one of them is in real inboxes. The instinct to normalise it all away immediately is the right instinct and the wrong first move, some of that mess is signal. Angry customers do write in capitals.
Tokenising
print('raw %r' % text)
print('stripped %r' % text.strip())
print('lowered %r' % text.strip().lower())
print('naive split %s' % text.strip().lower().split())
import re
tokens = re.findall(r"[a-z0-9']+", text.lower())
print('regex %s' % tokens)
stripped 'THE ROUTER is Not working!! since tuesday.'
lowered 'the router is not working!! since tuesday.'
naive split ['the', 'router', 'is', 'not', 'working!!', 'since', 'tuesday.']
regex ['the', 'router', 'is', 'not', 'working', 'since', 'tuesday']
split() leaves working!! and tuesday. as tokens distinct from working and tuesday, which quietly doubles parts of your vocabulary and halves the evidence for each. The regex keeps letters, digits and apostrophes and drops the rest.
Building a bag of words by hand
from collections import Counter
docs = ['the router is not working',
'the router is working',
'my line is not slow']
tokenised = [re.findall(r"[a-z0-9']+", d.lower()) for d in docs]
vocab = sorted({t for doc in tokenised for t in doc})
index = {t: i for i, t in enumerate(vocab)}
print('vocabulary (%d): %s' % (len(vocab), vocab))
import numpy as np
M = np.zeros((len(docs), len(vocab)), dtype=int)
for r, doc in enumerate(tokenised):
for token, n in Counter(doc).items():
M[r, index[token]] = n
print('\ndocument-term matrix:')
print(' ' + ' '.join('%-8s' % v[:7] for v in vocab))
for r, row in enumerate(M):
print('doc %d ' % r + ' '.join('%-8d' % v for v in row))
document-term matrix:
is line my not router slow the working
doc 0 1 0 0 1 1 0 1 1
doc 1 1 0 0 0 1 0 1 1
doc 2 1 1 1 1 0 1 0 0
Read rows 0 and 1 again
They differ by one word and mean opposite things. In this representation they differ by one column out of eight, and nothing records that not was sitting immediately before working. Every limitation in the next two days follows from that single fact.
The same thing from scikit-learn
import pandas as pd
tickets = pd.read_csv('tickets.csv')
tickets['text'] = tickets['text'].fillna('').str.strip()
tickets = tickets.drop_duplicates(subset='ticket_id').reset_index(drop=True)
from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer()
M = vec.fit_transform(tickets['text'])
print('matrix %s, %.4f%% non-zero'
% (M.shape, 100 * M.nnz / (M.shape[0] * M.shape[1])))
print('stored as %s' % type(M).__name__)
counts = np.asarray(M.sum(axis=0)).ravel()
names = vec.get_feature_names_out()
print('\nmost common tokens:')
for i in counts.argsort()[:-1][:12]:
print(' %-14s %5d' % (names[i], counts[i]))
stored as csr_matrix
most common tokens:
help 3
invoce 6
internt 21
conection 26
competitor 83
better 83
package 83
offered 83
considering 85
options 85
am 85
customer 92
.toarray() on a large one is the quickest way to exhaust your memory.