Mit Beginn der Pandemie haben viele ihr Einkaufsverhalten umgestellt: Statt wie früher mit Einkaufszettel und Anonymität gewappnet den Supermarkt zu betreten, nutzt man heutzutage den bequemen Lieferdienst. Der erlaubt alle Produkte in heimischer Ruhe auszusuchen und bei Folgebestellungen ganz einfach frühere Produkte erneut in den Warenkorb zu legen. Die dabei anfallenden Daten – samt Zahlungsmethoden – bewahrt der Supermarkt fein säuberlich auf.

Dabei kommt schnell einiges zusammen, denn anders als in großen Onlineshops wie Amazon oder Zalando kauft man bei Lebensmittelhändlern Produkte oft wiederholt und mit hoher Frequenz. Nicht selten wird zwei- bis dreimal pro Monat oder öfter ein Warenkorb geliefert, der zu beachtlichen Teilen aus den immer gleichen Produkten besteht.

Direkt nach dem Login wird eine Liste der bereits bestellten Produkte präsentiert. Praktisch für den nächsten Einkauf und es zeigt, was der Händler alles über seine Kunden weiß.
Direkt nach dem Login wird eine Liste der bereits bestellten Produkte präsentiert. Praktisch für den nächsten Einkauf und es zeigt, was der Händler alles über seine Kunden weiß.

Manche Onlinehändler bieten löblicherweise einen Datenexport an. Zu dieser Gruppe gehört die Supermarktkette Rewe, deren Export-Daten wir als Beispiel für diesen Artikel nutzen. Rewe-Kunden bekommen in den Einstellungen des Onlineshops mit wenigen Klicks ausgeliefert, was der Händler über sie weiß – aber was fängt man mit den paar Hundert Kilobyte Einkaufsdaten im JSON-Format an? Außerdem weist Rewe darauf hin, dass die Bestelldaten nicht in einem interoperablen Format zur Verfügung stehen, weil es dafür kein solches Format gebe. Also kann man nicht zu standardisieren Tools greifen, sondern muss bei der Datenanalyse selbst Hand anlegen.

Komfortabler Datenexport: Erst klicken, dann Mail abwarten.
Komfortabler Datenexport: Erst klicken, dann Mail abwarten.

Im Folgenden erklären wir, wie man so einen Datenexport analysiert und die Datenqualität verbessert. Die konkreten Daten sind spezifisch für die Einkäufe eines einzelnen Kunden, ihre Zeitpunkte und den Händler, aber die allgemeine Vorgehensweise kann man breit anwenden – sofern man an die Daten kommt.

Entheddern

Als meistverwendete Programmiersprache für alle möglichen Datenanalysen hat sich Python etabliert. Gepaart mit Jupyter Notebooks, dem Schweizer Taschenmesser für flinke Entwicklung von Prototypen, kommt man damit schnell zu ansehnlichen Ergebnissen. Doch vor dem Anlegen eines frischen Jupyter-Notebooks ist es sinnvoll, sich die Struktur der Export-Datei anzusehen. Zum komfortablen Lesen von größeren JSON-Dateien bietet sich der Browser Firefox oder das Kommandozeilentool jless an. Der Export der Daten von Rewe sieht stark gekürzt folgendermaßen aus:

{
  "addressData": ...,
  "baskets": ...,
  "customerDate": ...,
  "paymentData": ...,
  "orders": {
    "orders": [ ... ]
  }
}

Wie gesagt, der Aufbau der Daten ist nicht standardisiert. In der Zukunft, bei anderen Händlern oder gänzlich anderen JSON-Exporten ist das Bild ein anderes – aber die Inspektion per Browser oder jless dient ja genau dazu, sich einen Überblick zu verschaffen. Wenn Sie einfach nur die Datenanalyse nachvollziehen wollen, können Sie einen anonymisierten und gekürzten Datensatz herunterladen.

Neben den reinen Bestelldaten listet die Datei noch zahlreiche andere personenbezogene Informationen auf. Die eher sinnlose Verschachtelung eines orders-Arrays in einem orders-Objekt ist übrigens kein Druckfehler, sondern tatsächlich so in der Datei enthalten. Derartiges kommt in der Praxis gerne dann vor, wenn keine genauen Schnittstellendefinitionen vorliegen.

Jeder Eintrag in dem Array mit den Bestellinformationen enthält mehrere Unterobjekte, unter anderem für Zahlungs- und Adressinformationen – diese sogar als GPS-Koordinaten. Überlegen Sie sich also gut, ob Sie für die weitere Verarbeitung Ihrer personenbezogenen Daten eine Cloud-Software oder lieber lokale Programme nutzen. Das interessanteste Unterobjekt heißt subOrders:

{
  "invoiceAddress": ...,
  "payments": ...,
  "paybackInfo": ...,
  "subOrders": [{
    "deliveryType": "DELIVERY",
    "subOrderValue": 9300,
    "timeSlot": {
      "firstSlotDate": "202108040800",
      "lastSlotDate": "202108041300",
      "price": 0
    },
    "lineItems": [ ... ],
    "orderId": "B-ABC-DEF-123",
    "creationDate": "202108031245"
  }]
}

Neben der Auslieferung durch eigene Fahrer ("deliveryType": "DELIVERY") bietet der Lebensmittelhändler auch den Paketversand von haltbaren Produkten sowie Abholung im Laden an. Diese verschiedenen Bestelltypen kann man in einer Bestellung kombinieren. Unser Beispielkunde hat dergleichen nicht genutzt, weshalb es zu jeder Order nur eine Sub-Order gibt.

Der Schlüssel subOrderValue enthält den gesamten Bestellwert, hier glatte 93 Euro. Die Zahl unter creationDate gibt den Zeitpunkt der Bestellung an, allerdings nicht als Unix-Zeitstempel, sondern als String im Format Jahr, Monat, Tag, Stunde, Minute (ohne Trennzeichen). Das Objekt unter timeSlot gibt das Lieferzeitfenster an sowie eventuelle Extrakosten.

Bei der Auswertung chronologischer Daten ist es in der Regel sinnvoll, sie nach ihrem Zeitpunkt zu sortieren, zu gruppieren und so weiter. Für eine konsistente Analyse muss man sich bei diesem Datensatz also entscheiden, welchen der beiden Zeitpunkte man nutzen will; wir wählen das Bestelldatum und merken uns daher den Key creationDate.

Jetzt geht es endlich ans Eingemachte, die eigentlichen Bestellpositionen im lineItems-Array. Die Einträge stellen eine Art digitalen Kassenzettel mit Produkttitel, Einzelpreis, Anzahl und Gesamtpreis dar:

{
  "price": 40,
  "quantity": 3,
  "title": "Bio Banane",
  "totalPrice": 120
}

Auch hier sind die Beträge in Eurocent beziffert. Wer weiter durch die Daten scrollt, entdeckt, dass bei Mehrweggebinden auch das Pfand als „Produkt“ im Array auftaucht. Das sollte man – genau wie den mit null Euro bepreisten Eintrag „Getränke-Sperrgutaufschlag“ – vor der Weiterverarbeitung herausfiltern.

Schlangen und Bären

Nach dieser ersten Inspektion gibt es diverse Optionen für die weitere Analyse. Von der händischen Auswertung zur weitgehend automatischen Analyse über (Python-)Skripte ist vieles denkbar. Eine besonders vielseitige und schnell umsetzbare Auswertung baut man sich mit „Pandas“, einer mächtigen Python-Bibliothek zur Datenanalyse. Die Installation von Jupyter Notebooks, Pandas und einem Hilfsmodul für grafische Ausgaben gelingt sehr einfach über Pythons Paketmanager pip. Der zweite Befehl startet direkt eine Jupyter-Notebook-Instanz, die sich im Standardbrowser öffnet:

pip install jupyter pandas matplotlib
jupyter notebook

Den kompletten Code des im Folgenden beschriebenen Notebooks können Sie ebenfalls herunterladen. Navigieren Sie die Notebook-Ansicht zur Datei und klicken Sie darauf, um sie zu öffnen. Alternativ können Sie über den Button „New“ ein neues Notebook anlegen, um den Code selbst abzufassen.

Um die Daten mit Pandas zu analysieren, muss man sie aus dem hierarchischen JSON-Format in ein DataFrame-Objekt von Pandas konvertieren. So ein DataFrame ist im Grunde eine Liste von Datensätzen. Jeder Datensatz muss dabei die gleichen Schlüssel enthalten, damit Pandas aus der Liste eine Tabelle mit einheitlichen Spalten konstruieren kann. Dafür öffnet das Skript zunächst die Datenexport-Datei mittels des json-Moduls aus Pythons Standardbibliothek:

import json
path = "export.json"
with open(path, encoding = "utf-8") as file:
  data = json.loads(file.read())

Anschließend klopft der Code die Hierarchie der JSON-Daten flach, indem er über jede Bestellposition in jeder Sub-Order in jeder Order wandert und die Bestelldaten samt der zugehörigen Order- und Sub-Order-Information speichert. Dadurch entsteht zwar Redundanz, aber häufig sind solche tabellarischen Daten einfacher als Hierarchien zu handhaben. Pythons „dictionary unpacking“-Syntax mit dem Doppelstern, kombiniert mit drei Generator-Ausdrücken, die über alle drei Hierarchieebenen iterieren, erledigen die Aufgabe in vier Zeilen:

table = ({**ord, **subOrd, **item}
  for ord in data['orders']['orders']
  for subOrd in ord['subOrders']
  for item in subOrd['lineItems'])

Im nächsten Schritt kommt Pandas ins Spiel, denn das flache Python-Dictionary wird in einen DataFrame importiert. Außerdem ergänzen wir die Spalte date, die das Bestelldatum – konvertiert in ein Python-datetime-Objekt – enthält. Pandas’ DataFrame implementiert den []-Operator, was das Hinzufügen von Spalten sehr einfach macht:

import pandas
df = pandas.DataFrame(table)

df['date'] = pandas.to_datetime(df['creationDate'])

Die alte Spalte creationDate braucht man jetzt nicht mehr, genau wie viele andere der insgesamt 26 Spalten. Der nächste Codeschnipsel filtert daher die sieben interessanten Spalten heraus, was ebenfalls mit dem []-Operator leicht gelingt:

df = df[['date', 'orderId', 'orderValue', 'title', 'price', 'quantity', 'totalPrice']]

Außerdem ist jetzt ein guter Zeitpunkt, um irrelevante Zeilen wie die erwähnten Pfandbeträge zu filtern. Hier hilft Pandas’ „boolean indexing“, die Tilde (~) dient als NOT-Operator:

dummies = ["Enthaltene Pfandbeträge", "Getränke-Sperrgutaufschlag", "TimeSlot"]
df = df[~df['title'].isin(dummies)]

Zahlreiche weitere solcher Selektionsmethoden findet man in Pandas’ Dokumentation. Zum Schluss legt set_index() das Datum als Index fest, um chronologische Auswertungen zu vereinfachen:

df = df.set_index(['date'])

Erste Analysen

Diesen Code auszuführen dauert weniger als eine Sekunde. Im geöffneten Notebook drücken Sie dafür einfach wiederholt auf den Play-Button, um Schritt für Schritt die Codeschnipsel auszuführen. Den Pfad zur Datei mit dem JSON-Export müssen Sie eventuell anpassen. Wenn man sich den produzierten DataFrame ausgeben lässt, indem man in einer Zelle des Notebooks nur die Variable df notiert, dann zeigt Jupyter automatisch eine hübsche Tabelle an. In unserem Fall besteht der Datensatz aus insgesamt 1049 Bestellpositionen.

Dank der Integration zwischen Pandas und Jupyter zeigt das Notebook DataFrame-Objekte tabellarisch an.
Dank der Integration zwischen Pandas und Jupyter zeigt das Notebook DataFrame-Objekte tabellarisch an.

Jetzt sind erste statistische Auswertungen möglich. Beispielsweise ist interessant, wie viel Geld man ungefähr monatlich für Lebensmittel ausgibt. In Pandas formuliert sieht das so aus:

df.resample('M').sum()['totalPrice'].plot(kind='bar')

Die Codezeile gruppiert den DataFrame nach Kalendermonaten (resample('M')) und berechnet die monatlichen Summen aller numerischen Spalten (sum()). Vom Ergebnis wird die Spalte mit den Preisen ausgewählt (['totalPrice']). Außerdem kommt gleich noch die Bibliothek Matplotlib zum Zug (plot(kind='bar')) und gibt das Resultat als Balkendiagramm aus. Um den import von Matplotlib kümmert sich Pandas automatisch. Ohne den Aufruf von plot() werden die Daten als tabellarischer Text ausgegeben.

Balkendiagramm der monatlichen Lieferbeträge (angegeben in Cent). Die Lücke im August 2021 deutet auf Urlaub hin.
Balkendiagramm der monatlichen Lieferbeträge (angegeben in Cent). Die Lücke im August 2021 deutet auf Urlaub hin.

Im Diagramm sieht man deutlich, dass unser Beispielkunde im August 2020 das meiste Geld ausgegeben hat, aber im August 2021 gar keins. Das deutet auf einen sommerlichen Urlaubsaufenthalt hin. Beim Einkaufsverhalten handelt es sich also um wirklich sensible Daten, die allerhand Schlüsse zulassen und die man sorgsam behandeln muss.

Fluktuationen

Mit ähnlichen Abfragen kann man jetzt viele weitere Analysen anstellen. Beispielsweise, welches Produkt man am häufigsten gekauft hat oder für welches das meiste Geld ausgegeben wurde. Die Pandas-Dokumentation bietet einige Tutorials, wie man solche Fragen beantwortet.

Uns hat zunächst interessiert, bei welchem Produkt der Preis am meisten geschwankt hat. Diese Fluktuation kann man grob abschätzen, indem man den Mindest- und Höchstpreis eines Produktes über den ganzen Lieferzeitraum ins Verhältnis setzt, zum Beispiel über die Formel (Max − Min) ÷ (Max + Min). Der passende Pandas-Code, sieht wie folgt aus:

df = df.groupby('title')

min = df.min()['price']
max = df.max()['price']

fluc = (max - min) / (max + min)
fluc.sort_values(ascending=False).to_frame('Fluctuation')

Zunächst gruppiert man die Tabelle per groupby() nach identischen Produkttiteln. (Das weiter oben genutzte resample() dient speziell zur zeitlichen Gruppierung.) Der Einfachheit halber verwenden wir die Variable df wieder. Wer auch im folgenden Code weiter Zugriff auf die ungruppierten Daten haben will, muss stattdessen eine neue Variable nutzen. Bei der Gruppierung wird der Produkttitel automatisch auch als Index für das Ergebnis genutzt. Die nächsten beiden Zeilen selektieren Minimum beziehungsweise Maximum vom Preis jedes Produktes. Dabei entstehen Objekte, die Pandas als „Series“ bezeichnet. Konzeptuell handelt es sich um eindimensionale Arrays, allerdings inklusive Metadaten wie etwa einem Index (wofür in diesem Fall weiter der Produkttitel genutzt wird). Auf diesen Arrays kann man arithmetische Operationen und Sortierungen durchführen, was wir für die Fluktuationsberechnung nutzen; das Ergebnis ist wieder eine Series.

Zum Schluss wird die Series sortiert und zurück in einen DataFrame konvertiert, damit Jupyter eine schöne Tabelle anzeigt. Das Ergebnis zeigt, dass bei den Einkäufen des Beispielkunden insbesondere Gemüse eine hohe Preisspanne aufweist, angeführt von der Bio-Paprika mit knapp 50 Prozent Fluktuation.

Zu den fünf Produkten mit der größten Preisfluktuation gehört interessanterweise auch Reis.
Zu den fünf Produkten mit der größten Preisfluktuation gehört interessanterweise auch Reis.

Einheiten

Wie man in den gezeigten Tabellen sieht, tragen einige Produkte eine Maßeinheit im Namen, was Vergleiche zwischen Produkten schwierig macht. Abhilfe schafft eine schnell geschriebene Klasse (Unit) mit einer statischen parse()-Methode. Die sucht nach Gewichts- und Volumenangaben in Produktnamen und bildet sie auf Werte ab, mit denen man Berechnungen und Vergleiche anstellen kann:

from units import Unit, UnitType

p = Unit.parse("Pils 24x0,33l")
w1 = Unit.parse("Hefeweizen 5L Fass")
w2 = Unit.parse("Weißbier 6x0,5l")
p > w1 + w2 # Ergibt False

Der Parser nutzt einen recht simplen regulären Ausdruck, andere Eingangsdaten können durchaus Anpassungen erfordern. Der Code soll nicht „production ready“ sein, sondern möglichst schnell und einfach Analysen ermöglichen.

In Pandas kann man die gruppierten Daten nun um eine neue Spalte mit verarbeiteten Maßeinheiten ergänzen:

df = df.sum()[['quantity']]
df['unit'] = df.index.map(Unit.parse)
df = df[df['unit'].notna()]
df['total'] = df['unit'] * df['quantity']

Die erste Zeile summiert zunächst alle numerischen Spalten der nach Titel gruppierten Produkte. Andere Spalten, für deren Werte Summen keinen Sinn ergeben, verwirft Pandas. Uns interessiert ohnehin nur die Spalte mit der Anzahl; der Aufruf [['quantity']] filtert danach. Der Produkttitel ist der Index dieses Data-Frames und bleibt deshalb ebenfalls erhalten. Die zweite Zeile nutzt genau diesen Index, um Werte für die neue Spalte unit zu berechnen. Eingetragen wird jeweils, was die parse()-Methode im Produkttitel findet. Produkte, bei denen parse() nichts findet – und daher None zurückgibt –, filtert die dritte Zeile mittels der Funktion notna() heraus. (Mit „NA“ bezeichnet Pandas fehlende Werte, notna() filtert also nicht-fehlende, soll heißen, vorhandene Werte.) Die vierte Zeile nutzt den auf der Unit-Klasse implementierten Multiplikationsoperator, um die gesamte Bestellmenge zu berechnen.

Nach all diesen Vorarbeiten kann man sich zum Beispiel die – nach Masse – meistgekauften Produkte anzeigen lassen. Dabei hilft wieder das „boolean indexing“, um mit einer anonymen Hilfsfunktion (lambda …) all die Produkte auszuwählen, die eine Gewichtsangabe tragen. Anschließend wird die Liste nach der Gesamtbestellmenge sortiert:

df_weight = df[df['unit'].map(lambda unit: unit.unit_type == UnitType.WEIGHT)]
df_weight.sort_values('total', ascending = False)

Angeführt wird die Liste unseres Beispielkunden von Milchprodukten; abgeschlagen auf den letzten Plätzen liegen Kräuter. Für Produkte mit Volumenangaben kann man analog nach UnitType.VOLUME filtern. Kurios ist übrigens, dass stückige Tomaten eine Volumenangabe haben, passierte Tomaten aber eine Gewichtsangabe.

Der Beispielkunde hat nach Gewicht ordentlich Frischkäse und Jogurt konsumiert. Diese Produkte führen in der Liste mit gut 19 beziehungsweise 18,5 Kilogramm.
Der Beispielkunde hat nach Gewicht ordentlich Frischkäse und Jogurt konsumiert. Diese Produkte führen in der Liste mit gut 19 beziehungsweise 18,5 Kilogramm.

Bei Kollegen und Freunden, die Einblick in ihre Datensätze gaben, konnten wir mit solchen Methoden Interessantes herausfinden. Beispielsweise hat ein Bekannter über mehrere Jahre hinweg mehr als drei Hektoliter verschiedener Mategetränke umgesetzt – beim Filtern muss man beachten, dass der Produktname „Mate“, aber nicht „Tomate“ enthalten soll. Bei einer maisbegeisterten Freundin liegt Dosenmais mit insgesamt knapp 100 Litern auf Platz eins der Liste – aber erst, nachdem die „Müllbeutel mit Zugband 60l“ aussortiert wurden.

Fazit

Mehr Qualität, bitte!

Die bis hierher gewonnenen Erkenntnisse haben im Freundeskreis für Interesse, Erheiterung und auch betretenes Schweigen gesorgt. Wer selber mit Python und Pandas herumspielt, dem bieten die Daten ein breites Feld für verschiedenste Experimente. Der nächste große Schritt ist, die Daten mit eindeutigen Artikelnummern, EAN-Codes oder Produktkategorien anzureichern. Dann könnte man ähnliche Produkte besser vergleichen und die Analyse mit externen Informationen verknüpfen. Weil sich diese Daten aber nicht im Export finden, muss man zusätzliche Datenquellen auftun. Wie man solche Quellen sucht und findet und wie weit wir im konkreten Fall bei Rewe kamen, beschreiben wir in einem Folgeartikel.