If you are dealing with a large collections of documents, you will often find yourself in the situation where you are looking for some structure and understanding what is contained in the documents. Here I’ll show you a convenient method for discovering and understanding clusters of text documents. The method also works well for non-text features, where you can use it to understand the importance of certain features for the cluster. The shown method is somewhat related to topic models.

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.style.use("ggplot")

Load dataset

First we instructions of the german recipes dataset and drop the duplicates.

In [2]:
df = pd.read_csv("recipes.csv", usecols=["Instructions"]).drop_duplicates()
df.head()
Out[2]:
Instructions
0Die Eier hart kochen. Dann pellen und mit eine…
1Vorab folgende Bemerkung: Alle Mengen sind Cir…
2Die Kirschen abtropfen lassen, dabei den Saft …
3Den Spargel säubern, die holzigen Enden abschn…
4Kohlrabi schälen und klein würfeln. Mit der Br…
In [3]:
texts = df.Instructions.values.tolist(); texts[0]
Out[3]:
'Die Eier hart kochen. Dann pellen und mit einem Eierschneider in Scheiben schneiden. Den Reis halbgar kochen und zur Seite stellen. Die Wurst (Kolbász) in dünne Scheiben schneiden.Den Knoblauch abziehen und fein würfeln. Die Zwiebel schälen, fein hacken und in etwas Fett glasig braten. Knoblauch und Hackfleisch dazu geben und so lange braten, bis das Hackfleisch schön krümelig wird. Den eigenen Saft nicht ganz verkochen lassen. Die Fleischmasse mit Salz, Pfeffer und Paprikapulver würzen.Das Sauerkraut kurz durchspülen, ausdrücken und abtropfen lassen (damit es nicht zu sauer wird). Das Sauerkraut in einen Topf geben und mit dem Kümmel und den Lorbeerblättern vermischen. Ca. 30 Minuten unter Zugabe von wenig Wasser bei niedriger Stufe dünsten.Eine feuerfeste Form mit etwas Öl einfetten und den Boden dünn mit Sauerkraut belegen. Darauf Kolbász und die Hälfte der in Scheiben geschnittene Eier verteilen, dann eine weitere dünne Schicht Sauerkraut drüber legen. Mit 1 Becher Schmand bedecken. Nun das Hackfleisch mit dem Reis mischen und auf der Sauerkrautschicht gleichmäßig verteilen. Mit der zweiten Hälfte der Eier belegen und die dritte Schicht Sauerkraut oben drauf verteilen. Wenn noch Zutaten (Hackfleisch-Reis-Masse und Sauerkraut) übrig sind, kann man diese Schichten weiter so legen. Ganz oben kommt eine Schicht Sauerkraut. Auf diese letzte Schicht verteilt man noch den Rest vom Schmand (sollte reichlich sein). Den Speck in dünne Scheiben schneiden und damit alles abdecken. Mit etwas Öl besprenkeln. Die Form mit einem Deckel verschließend.Im vorgeheizten Backofen bei 175°C ober-/Unterhitze gut 1 Std. garen.Ganz frische Baguettebrötchen oder Weißbrot schmeckt am allerbesten dazu. Man kann aber auch Kartoffel- oder Semmelknödel dazu reichen. Etwas aufwendig, aber die Mühe lohnt sich... es ist einfach mega-ober-lecker.Anmerkung: Für viele sind Eier in Verbindung mit Sauerkraut ein No-Go, man kann sie also auch weglassen.'

Run k-means clustering

Now we run the well-known k-means clustering algorithm on the tfidf vectors of your documents.

In [4]:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from nltk.corpus import stopwords
In [5]:
vectorizer = TfidfVectorizer(
    stop_words=stopwords.words('german'),
)

X = vectorizer.fit_transform(texts)

We will just go for 15 clusters. Depending on your data you have to tune this number carefully.

In [6]:
model = KMeans(n_clusters=15, init='k-means++',
               precompute_distances=True, random_state=2019,
               max_iter=100, n_init=16, n_jobs=16)

model.fit(X)
Out[6]:
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=100,
       n_clusters=15, n_init=16, n_jobs=16, precompute_distances=True,
       random_state=2019, tol=0.0001, verbose=0)
In [7]:
df["cluster"] = model.predict(X)
In [8]:
df.cluster.value_counts()
Out[8]:
3     566
0     554
8     498
5     362
4     346
7     320
12    309
9     294
6     208
2     194
14    181
10    163
1     128
11    104
13     64
Name: cluster, dtype: int64

Now we can look at some examples from certain clusters.

In [9]:
df[df.cluster == 4].sample(5).Instructions.values.tolist()
Out[9]:
['Margarine Zucker und Eigelb schaumig rühren und den Quark einrühren. Den Zwieback klein schlagen und mit den Mandeln unter die Quarkmasse rühren. Das steif geschlagene Eiweiß unterheben.Die Äpfel in kleine Würfelchen schneiden und mit Zucker und Zimt mischen. Die Äpfel in eine Auflaufform geben. Die Zwiebackmasse darauf verteilen. Bei 200°C ca. 35 Minuten backen.Kann heiß oder kalt mit Schlagsahne gegessen werden.',
 'Quark mit Mehl, Milch, Öl und Salz zu einem Teig verkneten.Den Lauch putzen und in feine Ringe schneiden, in Butter andünsten.Petersilie waschen und fein hacken.Soja-Milch mit Eigelb, Käse und Petersilie mischen und mit den Gewürzen abschmecken.Den Backofen auf 200°C vorheizen. Den Teig in einer Form auslegen.Lauch und die Käse-Milch-Mischung darauf geben und mit den Nüssen bestreuen.Etwa 35-40 Min. backen.',
 'Mehl und Milch mischen. Zucker und Vanillezucker hinzufügen. Eier und Salz unterrühren. Zimt nach Geschmack unter den Teig mischen (wir nehmen etwa 1 EL).Öl in einer Pfanne erhitzen und Eierkuchen braten.PS: Uns schmecken die Eierkuchen so gut, das wir keinen Aufstrich brauchen!',
 'Zucker und Hefe in der Milch auflösen und etwa 5 - 10 min stehen lassen. Es bilden sich dann kleine Bläschen. Mehl und Salz in eine große Schüssel geben, Hefemischung und Eiweiß hineingeben und alles kräftig zu einem elastischen Teig verkneten, mit der Hand ca. 7 Minuten. Zwischendurch Wasser nach Bedarf zugeben, der Teig soll nicht zu fest sein. Am Ende den EL Öl dazugeben, die Teigkugel damit benetzen und den Teig zugedeckt 1 - 2 Stunden gehen lassen, bis er sich verdoppelt.Die Zwiebeln hacken, ebenso die Petersilie. Tomate in kleine Stücke schneiden. Die Knoblauchzehe zerdrücken. Die Zutaten für die Füllung inzwischen miteinander vermengen. Nicht von der Menge der Zwiebeln abschrecken lassen, die sind wichtig, damit der Hackfleischbelag sich nicht zu sehr zusammenzieht.Den Teig in 10 -12 Kugeln teilen. Eine Kugel mit etwas Mehl zu einem ovalen Gebilde/einem Riesenei ausrollen (nicht zu dünn). In der Mitte längs 1 - 2 EL der Füllung verteilen, fast über die gesamte Länge. Dann die Ränder der Längsseite einklappen, ohne dabei die Füllung ganz zu bedecken. An den Spitzen des ovalen Teigstücks noch mal ein wenig einschlagen, um das typische Aussehen der Pide zu bekommen. Auf ein mit Backpapier ausgelegtes Backblech legen (bei mir passen je nach Größe 3 - 4 Stück darauf).Die Teigränder mit dem Eigelb bestreichen und die Pide im gut vorgeheizten Backofen bei 180 °C ca. 20 - 25 min backen. Nach dem Backen sofort mit ein wenig geschmolzener Butter bestreichen, damit sie nicht hart werden.',
 'In einer Schüssel die Hefe und den Zucker im lauwarmen Wasser auflösen. Dann Mehl, Salz, Joghurt und Sonnenblumenöl hinzufügen und den Teig mit der Hand kneten, bis er weich und glatt ist. An einem warmen Ort mindestens 45 Minuten zugedeckt gehen lassen.Inzwischen den frischen Spinat waschen und die Stiele entfernen. Die Zwiebel klein schneiden und in der Pfanne anschwitzen. Den gewaschenen Spinat ebenfalls klein schneiden, zu den Zwiebeln geben und andünsten.Mit Salz, Pfeffer und Muskat nach Geschmack würzen. Den Spinat etwas abkühlen lassen. Den Schafskäse in sehr kleine Stücke schneiden und unter den Spinat rühren. 3 Eier unter die Masse heben.Den Teig in 4 - 6 gleich große Portionen teilen und zu länglich-ovalen Fladen ausrollen. Die Spinat-Schafskäse-Masse in die Mitte geben, die Ränder nach innen klappen und an den Enden verdrehen.Ein Ei verquirlen und auf die sichtbare Spinatmasse pinseln. Zuletzt 1 Eigelb mit der Milch verquirlen, die Ränder damit bestreichen und mit Schwarzkümmel bestreuen.Im vorgeheizten Backofen bei 200 °C (Ober-/Unterhitze) mindestens 20 Minuten backen.']

It looks like, the cluster contains recipes that use some kind of dought (“Teig” in german). But understanding clusters like this is a tedious and time-consuming work and also error-prone since some overall clusterpattern are easily overlooked. That’s what we solve in the next section.

Discover cluster topics

We will now use an interpretable supervised machine learning model on the clusters to understand their internal structure. We settle for a simple logistc regression here, but depending on your usecase also other models can work well.

We fit a logistic regression for each cluster, trying to distinguish the cluster from the other datapoints. Then we use the coefficients of the model to find the most relevant features, which are just words in this case.

In [10]:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score
from sklearn.model_selection import cross_val_predict
In [11]:
lr = LogisticRegression(n_jobs=2, penalty="l1",
                        multi_class="ovr", C=10.0,
                        random_state=2019, solver="saga")

proba = cross_val_predict(lr, X, df.cluster, cv=10, n_jobs=8, method="predict_proba")
In [12]:
preds = proba.argmax(axis=1)
In [13]:
accuracy_score(df.cluster, preds)
Out[13]:
0.862036821253787

So we can determine the clusters with a resonable quality.

Let’s look at the coefficients of the logistic regressions. Since we fitted this multiclass model in a one-vs-all (ovr) schema, we have basically 15 logistic regressions. So we can check which words are important for distingushing the cluster from all other clusters.

In [14]:
lr.fit(X, df.cluster);
lr.coef_.shape
Out[14]:
(15, 14891)

Now we can look at the relevat words per cluster in an overview and understand the content of your document collection.

In [15]:
def get_important_words(cluster, lr, vectorizer, n=10):
    inv_vocab = {v:k for k,v in vectorizer.vocabulary_.items()}
    coef = lr.coef_[cluster]
    top_n = coef.argsort()[-n:][::-1]
    print([(inv_vocab[k], coef[k]) for k in top_n])
In [16]:
for c in sorted(df.cluster.unique()):
    print(f"Cluster: {c}")
    get_important_words(cluster=c, lr=lr, vectorizer=vectorizer, n=10)
    print(126*"#")
Cluster: 0
[('soße', 14.515481975984175), ('wasser', 14.375954766790942), ('rouladen', 11.804888328687849), ('sauce', 11.515668213344016), ('klopfen', 11.210325715409844), ('topf', 11.040714110003092), ('schmalz', 10.564051222028002), ('beilagen', 9.926679260534398), ('derweil', 9.894159832663806), ('anschmoren', 9.31997470741715)]
##############################################################################################################################
Cluster: 1
[('marinade', 37.76101157530037), ('grillen', 16.973527698373175), ('spieße', 14.943066381438813), ('stecken', 8.399310302913722), ('grill', 7.2326468952699905), ('kühlschrank', 6.980152050463958), ('marinieren', 6.638999235769488), ('lammkoteletts', 6.113595115948132), ('bepinseln', 5.9554963529998295), ('sorgfältig', 5.469534183615525)]
##############################################################################################################################
Cluster: 2
[('zucchini', 38.34883471183204), ('paprika', 23.786279378798678), ('aubergine', 10.35420084260056), ('gemüse', 8.52935742686246), ('würfel', 8.362633752786166), ('geben', 8.31639686798775), ('tomatenwürfel', 8.259621020046211), ('tomatensauce', 7.501741804186635), ('tortilla', 7.362948011168674), ('häuten', 7.321994646457778)]
##############################################################################################################################
Cluster: 3
[('reibe', 10.195215540215418), ('semmelbrösel', 9.186359074559858), ('reste', 9.104437234901974), ('salat', 8.978386712901052), ('gurke', 8.888639317330183), ('essen', 8.757618856314767), ('frikadellen', 8.572187245030722), ('gegeben', 8.3274970527755), ('avocado', 8.232120830561302), ('notwendig', 8.176840123454575)]
##############################################################################################################################
Cluster: 4
[('teig', 44.59582209280804), ('mehl', 12.410046842456936), ('ausrollen', 11.102212426637811), ('hefe', 10.564202933120347), ('eiweiß', 10.16932589871433), ('bemehlten', 9.699048780062942), ('backen', 8.735488707826928), ('gehen', 8.442547664377265), ('topfenmasse', 8.104026956071243), ('oberfläche', 7.91116326909228)]
##############################################################################################################################
Cluster: 5
[('schneiden', 14.023993622007096), ('putzen', 13.884846191090423), ('wok', 13.012307403222826), ('hühnerbrüste', 12.362992958555058), ('dünsten', 10.708067757350122), ('lauch', 10.437087936880829), ('minuten', 10.278673221159409), ('streifen', 10.108286370859517), ('erhitzen', 9.623847278946094), ('schupfnudeln', 9.497738257532749)]
##############################################################################################################################
Cluster: 6
[('suppe', 49.00169932466905), ('bringen', 7.3142261383469), ('sieb', 7.041944734151656), ('pürieren', 5.590997893707628), ('brotscheiben', 5.510946636712884), ('topf', 5.0822125493575925), ('köcheln', 4.9300451676151535), ('würstchen', 4.767242039766096), ('restliches', 4.422940605218814), ('karotten', 4.183618241287214)]
##############################################################################################################################
Cluster: 7
[('kartoffeln', 39.89850027285719), ('schälen', 13.120399740900023), ('pellen', 9.041568644329166), ('bohnen', 8.310102142533085), ('kräuterquark', 8.162271767837781), ('ebenfalls', 7.940078769848744), ('gebräunt', 7.9393348592550055), ('ggf', 7.4139987702608705), ('darunter', 7.3480841448767995), ('waschen', 7.183698914230761)]
##############################################################################################################################
Cluster: 8
[('auflaufform', 21.3553773368933), ('backofen', 16.878332790219588), ('käse', 11.570266462545431), ('ofen', 10.932711006212822), ('minuten', 10.409135973526388), ('grad', 10.37805221831634), ('auflauf', 10.375118477855182), ('40', 10.240591880511344), ('180', 10.189502343105087), ('schichten', 9.95820163575737)]
##############################################################################################################################
Cluster: 9
[('fleisch', 49.21456168882674), ('bräter', 11.546923855891935), ('schmoren', 8.465495270083734), ('suppengrün', 7.732525262633197), ('rotwein', 7.655230119817561), ('zugedeckt', 7.081883280818754), ('einreiben', 6.974673058984896), ('fleischbrühe', 6.540515237307566), ('90', 6.08001996282407), ('bratfett', 5.979537904561793)]
##############################################################################################################################
Cluster: 10
[('fisch', 46.693997692740524), ('fischfilets', 13.901914444904463), ('karpfen', 9.588915706685126), ('salzen', 7.521039742040993), ('darauf', 7.496142103520844), ('legen', 7.411602165001324), ('platte', 6.59460894262458), ('tupfen', 6.478059541165482), ('filets', 5.34976021531174), ('schalotte', 5.349313252573292)]
##############################################################################################################################
Cluster: 11
[('spargel', 42.68318727818829), ('abschneiden', 6.969960332858439), ('cm', 5.688341207463093), ('eimasse', 3.688732186868523), ('eigelb', 2.9051827397709684), ('spargelenden', 2.0989927438253626), ('pfannkuchen', 1.524754279865801), ('holzigen', 1.4715597204780357), ('röllchen', 0.8398705927791598), ('eischeiben', 0.5944498029534152)]
##############################################################################################################################
Cluster: 12
[('dazugeben', 29.027641610224617), ('reis', 25.77744817831365), ('köcheln', 15.263275275558852), ('kcal', 8.436753581201797), ('ablöschen', 8.309768743226476), ('linsen', 8.263150136916396), ('umrühren', 7.9391600279522985), ('hühnerbrühe', 7.743048718252356), ('anbraten', 7.579214959606639), ('dafür', 7.451011900299091)]
##############################################################################################################################
Cluster: 13
[('schnitzel', 35.368769484727004), ('klopfen', 3.740330816513094), ('gemüsewasser', 3.0987297096920794), ('leicht', 2.9757579174113165), ('mettschnitzelchen', 2.3972964715999616), ('speckscheiben', 1.6223630843324968), ('mediterrane', 1.4951027068719862), ('schmandmischung', 0.8474628111781815), ('zahnstochern', 0.6657619205964926), ('rasch', 0.282444342569984)]
##############################################################################################################################
Cluster: 14
[('nudeln', 45.71500413175134), ('dente', 8.125991093938085), ('al', 8.0523026057696), ('salzwasser', 6.885099737430329), ('drauf', 6.702070480400417), ('nudelwasser', 5.733200527584745), ('chilischoten', 5.526745295283132), ('bissfest', 5.485186012225857), ('sojaschnetzel', 5.311117944820475), ('stiele', 4.745249059813284)]
##############################################################################################################################

For example, cluster 8 is about casserole (“Auflauf” in german) and cluster 14 is about noodle/pasta recipes (“Nudeln” in german).

This can be a handy method in your data science tool-kit to discover the structure of a dataset. Let me know what you did with it. Stay tuned for more!

You might also be interested in: