Mastodon Toots spielerisch verarbeiten - In Farbe und Bunt

Python bietet einen extrem schnellen Anschluss an viele Werkzeuge, auch für das Plotten von Diagrammen und allen erdenklichen grafischen Darstellungen

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:

  1. Die Datenquelle ist volatil, d.h. die Daten sind ggf. zu anderen Zeitpunkten nicht mehr reproduzierbar
  2. Die Datenquelle ist beim Extrahieren sehr langsam
  3. Die Daten aus der Quelle werden zu schnell geliefert und eine serielle Transformation würde den Strom blockieren
  4. 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:

Mastodon TagCloud (simple)

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:

Mastodon Image

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:

Mastodon TagCloud

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.