Einleitung
Nachdem nun das Mapping etwas in dem Toots Index mit dem letzten Blog-Artikel verfeinert wurde, können wir mal mit den Daten rumspielen.
Ich habe nun einige Toots aus der Lokalen Timeline meiner Instanz geladen. Wenn man mehr Schleifen-Durchgänge dem Script hinzufügt oder es mit Zeitversatz aufruft, bekommt man ältere oder neuere hinzu.
Mit diesem Grundstock an Statusnachrichten aus dem Fediverse ist es möglich mal ein paar “Analysen” auszuprobieren. Allerdings belasse ich es bei einer sehr spielerischen Sache, da für größere Analysen man erheblich mehr Daten benötigt. Das werden wir dann mal später durchführen.
Ich lade die Toots übrigens nicht nur aus Spaß in OpenSearch. Es gibt ein paar Gründe, warum man nicht direkt auf bestimmte Datenquellen zugreift, wenn man deren Daten analysieren wird. Man spricht hier von der Bevorzugung des ELT Ansatzes. In der IT haben sich grundsätzlich zwei Paradigmen der Datenintegration etabliert. ETL (Extract, Transform, Load) und ELT (Extract, Load, Transform). Wobei es ETL schon viel länger gibt. Im ETL Prozess werden aus einer Datenquelle die Daten extrahiert (über Schnittstellen oder Dateiformate) in ein Format und in die Struktur des Zielsystems transformiert und dabei ggf. normalisiert (manchmal sogar inhaltlich angepasst), dann abschließend in das Zielsystem importiert. Mit dem ELT Workflow verändert man einen entscheidenden Schritt in der Reihenfolge und erhält in der Konsequenz einige Vorteile. Bei ELT werden die Daten extrahiert und ohne große Formatänderungen (insbesondere nicht in der Struktur, Anreicherung oder Veränderung der Attribute) in einen Datenpool (ggf. ein Queue) geladen. Aus diesem Pool werden die Daten entnommen, und dann erst Zielprozessen übergeben, die eine Transformation und Verarbeitung durchführen.
Warum bevorzuge ich ELT? Es erfordert ja scheinbar etwas mehr Arbeit.
ELT ist aus mehreren Gründen geboten:
- Die Datenquelle ist volatil, d.h. die Daten sind ggf. zu anderen Zeitpunkten nicht mehr reproduzierbar
- Die Datenquelle ist beim Extrahieren sehr langsam
- Die Daten aus der Quelle werden zu schnell geliefert und eine serielle Transformation würde den Strom blockieren
- Die Datenquelle verwendet Quotas und schränkt damit Zugriffe ein, um Ressourcen zu schonen.
In Social-Media-Plattformen treffen wir meistens gleich auf mehrere dieser Gründe und daher habe ich mir angewöhnt, Daten, die ich aus solchen Systemen verarbeiten will, vorab in OpenSearch zu laden. Bei den Experimenten überschreite ich dann nicht irgendwelche Rate-Limits (die auch in der Mastodon API verwendet werden). Experimente bleiben für das geladene Datenset stabil (reproduzierbar) und ich bin nicht von der Verfügbarkeit des Systems abhängig.
Toots laden
In dem Nachbar-Blog zu OpenSearch habe ich das Mapping der Toots etwas verbessert. Am Ende des Artikels, dort ist auch noch mal das Script zum Laden von Toots in den Index. Man sollte den Durchlauf der Schleife auf zum Beispiel 50 erhöhen, um mehr Status-Nachrichten zum Experimentieren zu laden:
import json
from mastodon import Mastodon
from opensearchpy import OpenSearch
mastodon = Mastodon (
api_base_url='https://social.tchncs.de'
)
host = 'localhost'
port = 9200
client = OpenSearch(
hosts = [{'host': host, 'port': port}],
http_compress = True, # enables gzip compression for request bodies
use_ssl = False
)
# Der Name des Index
index_name = 'toots'
print ('Import toots')
page = None
for _ in range(50):
page = mastodon.timeline_local(limit=40) if page is None else mastodon.fetch_next(page)
for toot in page:
id = toot['id']
response = client.index(index_name, id=id, body=toot)
client.indices.refresh(index=index_name)
print ('Finished')
Wörter zählen
Mit den Verfeinerungen des Mappings haben wir im Content die Möglichkeit Aggregatsfunktionen auf den Text-Inhalt durchzuführen. Meine Idee ist es herauszufinden, wie häufig Wörter in all den Toots verwendet werden.
OpenSearch bietet dazu eine Funktion an, die genau das macht:
GET {{url}}/toots/_search
{
"size": 0,
"aggregations": {
"wordcount": {
"terms": {
"field": "content",
"size": "100",
"exclude": "[0123456789]+|http.*"
}
}
}
}
Wie man sieht, muss man einen Request-Body mit der GET-Methode schicken. "size": 0
ist komisch, bedeutet aber, dass wir an dem Ergebnis der Toots nicht interessiert sind, die bei der Suche gefunden wurden. Wir suchen über alle Toots und wollen nur Wörter zählen. Das aggregations
Objekt deklariert, was wir berechnet haben wollen. wordcount
ist unser frei gewählter Name (man kann nämlich auch mehrere Aggregationen durchführen, sogar verschachtelt. Da ist es gut, sprechende Namen zu vergeben). terms
ist die Art der Aggregation (auf Term-Ebene, also auf unseren indizierten Tokens aus dem Text). Die Funktion terms
unterstützt ein paar Parameter. Erstmal das Attribut, auf den wir aggregieren wollen (hier auf content
). Wir sind nur an den 100 häufigsten Termen (Wort-Abschnitte, Tokens) interessiert und Ziffernfolgen oder URLs sind keine “Wörter”, die wir gezählt haben wollen.
Man kann das in Postman ausführen (was ich für erste Tests immer so mache) oder auch in ein Python-Programm gießen. Da wir hier mit Python spielen, folgt gleich ein Stück Sourcecode. Ein Ergebnis in Postman sieht so aus (es ist immer gut zu wissen, wie eine Response aussieht, dann kann man einfacher dagegen programmieren):
{
"took": 155,
"timed_out": false,
"_shards": {
"total": 4,
"successful": 4,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2001,
"relation": "eq"
},
"max_score": null,
"hits": []
},
"aggregations": {
"wordcount": {
"doc_count_error_upper_bound": 18,
"sum_other_doc_count": 25902,
"buckets": [
{
"key": "mal",
"doc_count": 159
},
{
"key": "esc",
"doc_count": 133
},
...
Am Anfang wieder die Meta-Infos (155ms für die Ausführung mit 4 Shards, 2001 Toots wurden für die Aggregierung berücksichtigt, hits
ist leer, weil wir es so wollten)
Danach folgt das aggregations Objekt wordcount
mit statistischen Infos (ja, OpenSearch ist sehr geschwätzig) und in buckets
endlich die Ergebnisse. Das Wort mal
kommt 159 Mal in 2001 Toots im Feld content
vor, esc
ganze 133 Mal, usw. usf..
Cool. Wie sieht das in Python aus?
Hier das osWordCountOnCotnent.py
:
from opensearchpy import OpenSearch
def login (host, port):
# Client zu dem Dev Cluster (ohne SSL, ohne Anmeldung)
return OpenSearch(
hosts = [{'host': host, 'port': port}],
http_compress = True, # enables gzip compression for request bodies
use_ssl = False
)
def word_count (client, index):
aggs_wc={
"size": 0,
"aggregations": {
"wordcount": {
"terms": {
"field": "content",
"size": "100",
"exclude": "[0123456789]+|http.*"
}
}
}
}
return client.search(body=aggs_wc, index=index)
client = login('localhost', 9201)
words = word_count(client=client, index="toots")
buckets = words["aggregations"]["wordcount"]["buckets"]
for bucket in buckets:
print (f"{bucket['doc_count']} mal {bucket['key']}")
Das Ergebnis ist schön, aber leider auch etwas durchwachsen. Man wird immer noch eine Menge Allerweltswörter finden, die zwar einen Einblick in die Sprache geben, aber inhaltlich uns nicht weiterbringen. Ok, wir hatten ja auch keine spezifischen Anforderungen. Nur Wörter zählen. Aber es gibt einfach sehr viele Wörter, deren Anzahl wohl kaum jemanden interessieren wird.
Man könnte nun eine riesige Liste an Excludes definieren (und letztendlich daran scheitern) oder (was auch geht) ein include
festlegen, aber dann wird nur das gezählt, was wir selbst schon erwarten.
Tags zählen
Aber da gibt es ja was, was sich etabliert hat, seit dem es vermutlich die Menschheit gibt: Kategorisierungen. Dieses Prinzip wird sehr einfach in sozialen Medien mit Tags realisiert. Um solche Tags auszuzeichnen, nutzt man als Präfix das #
Zeichen (Hash) und daher heißen die auch Hashtags.
In unserer Analyse der Toots in einem älteren Blog-Artikel, konnte man sehen, dass die Mastodon API die Hashtags analysiert, extrahiert und im Content als Links formatiert und in dem Array tags
gesondert auflistet.
Und so haben wir die Informationen in unseren OpenSearch Index toots
kopiert und mit der letzten Verfeinerung des Mappings auch zugänglich gemacht.
Aber wie zählen wir nun die Häufigkeit dieser Tags? Die Aggregat-Funktion über Terme funktioniert nur in einem Feld. Das ist einer speziellen Implementation geschuldet, die extrem schnell eine zusammenhängende Wortliste durchzählt. Das im Heap der Instanzen aller OpenSearch-Nodes im Cluster und parallel über alle Shards (Apache-Lucene Prozesse). Abschließend werden die Daten dann zusammengezählt und zurückgeliefert.
Wie bekommen wir also die getrennt gespeicherten, einzelnen Tags aus dem Array in ein Feld, damit die Aggregierung funktioniert? Es gibt die Funktion “nested”, die genau das tut. Sie gibt an, welches Feld in einer tiefen Struktur für eine Aggregation zusammengeführt werden soll.
Als Abfrage sieht das so aus:
GET {{url}}\toots\_search
{
"size": 0,
"aggregations": {
"Nest": {
"nested": {
"path": "tags"
},
"aggregations": {
"tagcloud": {
"terms": {
"field": "tags.name",
"min_doc_count": 2,
"size": "100"
}
}
}
}
}
}
Das bedeutet nichts anderes, als: Betrachte alles unter tags
(pro Dokument) als eine Einheit und aggregiere tags.name
als Term. Letztendlich soll das Wort mindestens zwei Mal vorkommen und wir sind an den Top 100 interessiert.
Da wir nun eine verschachtelte Aggregation haben, rutschen die buckets
eine Hierarchiestufe tiefer:
...
"aggregations": {
"Nest": {
"doc_count": 1998,
"tagcloud": {
"doc_count_error_upper_bound": 8,
"sum_other_doc_count": 1750,
"buckets": [
{
"key": "esc",
"doc_count": 45
},
{
"key": "Chatkontrolle",
"doc_count": 35
},
{
"key": "twitter",
"doc_count": 16
},
...
Das sieht doch schon inhaltlich viel sinnvoller aus.
Es wird bunt: Eine Tag-Cloud
Wir sind noch nicht am Ende. Es wird, wie versprochen nun bunt. Datenanalysen haben häufig den Makel, dass sie letztendlich trockene Daten produzieren, die schwer zugänglich sein können. Der Mensch ist sehr kreativ, um dieses Problem zu lösen und hat Visualisierungen entwickelt, die sich in Diagrammen widerspiegeln. Das sind aber nicht nur Plots mit x/y Achse, sondern können vielfältige Aufbereitungen bedeuten. Die wohl verspielteste Lösung ist eine Wort-Wolke. Es gibt eine WordCloud Python Bibliothek, die genau sowas erzeugt (und wirklich einen Haufen an Abhängigkeiten benötigt, aber wir werfen uns trotzdem in das Vergnügen).
Damit es möglichst einfach geht, brauchen wir folgende Imports:
from opensearchpy import OpenSearch
from wordcloud import WordCloud
import multidict as multidict
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
Die notwendigen Bibliotheken mit pip
installieren (das wird wohl mindestens wordcloud sein, ggf. mehr, wenn ihr das noch nicht habt). WordCloud wird einiges ranziehen und auf einigen OS (wie Windows) auch noch extra Downloads fordern (Visual C++).
Nun das, was man erwarten würde:
def login (host, port):
# Client zu dem Dev Cluster (ohne SSL, ohne Anmeldung)
return OpenSearch(
hosts = [{'host': host, 'port': port}],
http_compress = True, # enables gzip compression for request bodies
use_ssl = False
)
def aggregate_tags (client, index):
aggs_tags={
"size": 0,
"aggregations": {
"Nest": {
"nested": {
"path": "tags"
},
"aggregations": {
"tagcloud": {
"terms": {
"field": "tags.name",
"min_doc_count": 2,
"size": "100"
}
}
}
}
}
}
return client.search(body=aggs_tags, index=index)
Die Buckets müssen wir für WordCloud in ein MultiDict umwandeln:
def create_frequency (buckets):
freqs = multidict.MultiDict()
for bucket in buckets:
freqs.add (bucket["key"], bucket["doc_count"])
return freqs
Jetzt nehmen wir einfach ein Beispiel aus dem WordCloud Tutorial:
def make_wordcloud_image_simple (freqs):
wc = WordCloud(max_words=1000, background_color="white")
wc.generate_from_frequencies(freqs)
plt.imshow(wc, interpolation="bilinear")
plt.axis("off")
plt.show()
Das rufen wir nun nacheinander auf:
client = login('localhost', 9201)
tags = aggregate_tags(client=client, index='toots')
buckets = tags["aggregations"]["Nest"]["tagcloud"]["buckets"]
tag_freq = create_frequency(buckets)
make_wordcloud_image_simple(tag_freq)
Wow. Unser Ergebnis:
Noch bunter: Mastodon Tag-Cloud
Es macht Spaß mit der WordCloud Bibliothek zu spielen, also setzen wir noch einen drauf. Die Bibliothek kann Word-Wolken auch in Schablonen anordnen. Was liegt da nicht näher mal ein Mamut zu nehmen:
Der Code dafür:
def make_wordcloud_image (freqs):
mastodon_mask = np.array(Image.open("mastodon_mask.png"))
wc = WordCloud(max_words=1000, mask=mastodon_mask,
background_color="white", contour_color="brown", contour_width=4)
wc.generate_from_frequencies(freqs)
plt.imshow(wc, interpolation="bilinear")
plt.axis("off")
plt.show()
...
make_wordcloud_image(tag_freq)
Das Ergebnis:
Das war nun ein Haufen Erklärung für nur ein paar Zeilen Code. Das zeigt wie man (mit entsprechenden Bibliotheken, Frameworks und Diensten) sehr komplexe Dinge in wenigen Programmzeilen erledigen kann. Allerdings sollte man den Überblick behalten, wie man das alles orchestriert.
Comments
No comments yet. Be the first to react!