Turning Sentences Into Tokens
Splitting, normalising, and the stop word that reverses your meaning
A computer cannot do arithmetic on a sentence. Everything in natural language processing begins with turning text into numbers, and the choices made in that step decide more about the final accuracy than the choice of model does.
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)
print('%d reviews, two labels each' % len(texts))
for t, s, c in list(zip(texts, sentiment, category))[:5]:
print('%-9s %-8s %s' % (c, 'positive' if s else 'negative', t))
laptop positive after a month the keyboard is still quite reliable, as described in the listing
laptop negative for the price the charger is not sturdy, second one i have bought
kitchen positive for the price the lid is hardly scratched, as described in the listing
kitchen negative for the price the blade is not solid, the packaging was fine
clothing positive the sleeve is very solid, as described in the listing
Step one: splitting into tokens
print('naive split on spaces')
print(text.split())
print()
import re
print('splitting on word boundaries')
print(re.findall(r"[a-z0-9']+", text.lower()))
['The', "kettle's", 'lid', "isn't", 'sturdy', '--', "I've", 'returned', 'it.', 'Cost', '24.99!']
splitting on word boundaries
['the', "kettle's", 'lid', "isn't", 'sturdy', "i've", 'returned', 'it', 'cost', '24', '99']
The naive split leaves punctuation glued to words, so it. and it become different things. The second version lowercases and strips punctuation, which fixes that and quietly creates new problems: isn't survives as one token, and the price has become two.
Normalisation, and what it destroys
STOP = {'the', 'is', 'a', 'and', 'it', 'to', 'of', 'i', 'was'}
def normalise(text, drop_stopwords):
words = re.findall(r"[a-z']+", text.lower())
if drop_stopwords:
words = [w for w in words if w not in STOP]
return words
for sentence in ['the pan is not sturdy', 'the pan is sturdy']:
print('%-24s -> %s' % (sentence, normalise(sentence, False)))
print()
for sentence in ['the pan is not sturdy', 'the pan is sturdy']:
print('%-24s -> %s' % (sentence, normalise(sentence, True)))
the pan is sturdy -> ['the', 'pan', 'is', 'sturdy']
the pan is not sturdy -> ['pan', 'not', 'sturdy']
the pan is sturdy -> ['pan', 'sturdy']
Removing stop words is not free
The usual advice is to drop common words because they carry no meaning. Look at what it did above: two sentences that mean opposite things became identical, because not is on almost every stop word list.
For topic classification this rarely matters. For anything involving negation, opinion or instruction it is fatal, and it is applied by default in a great deal of tutorial code.