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']
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}',
]
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])
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']
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}',
]
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])
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.