Warum ist der Umsatz gefallen?
Kurz gesagt: Ein Datenanalyse-Projekt von der Frage bis zum Bericht: bereinigen, Diagramme, eine Prüfung auf Rauschen, den Umsatzeinbruch nach Bestellanzahl, Kategorie, Tag und Kanal zerlegen und ein Modell für den Verlust mit Intervall.
Problem
„Warum ist der September so schwach?“
Der Chef hat den Bericht gesehen: Der Umsatz im September 2026 beträgt 7.128,80 €, im August waren es 8.812,70 €. Das sind 19,1 % weniger. Er hat drei Ideen auf dem Tisch: die Werbung stoppen, weil sie „nicht wirkt“, den Milchlieferanten wechseln oder Rabatte geben.
Jede Idee antwortet auf eine andere Ursache. Ist der Einbruch Rauschen, tust du nichts. Kommt er von einem Produkt, schaust du auf den Lieferanten. Kaufen die Leute weniger, denkst du an die Preise. Die Frage der Analyse ist also nicht „wie groß ist der Einbruch“, sondern „wo ist der Einbruch, damit wir wissen, was wir reparieren“.
In diesem Projekt gehst du den ganzen Weg des Kurses: die Daten, das Bereinigen, das Diagramm, die Prüfung auf Rauschen, das Zerlegen, das Modell und den Bericht. Die Zahlen kommen aus dem Bestelljahr des Shops, und die Antwort liegt nicht dort, wo die Ideen des Chefs sie erwarten.
Die Idee
Die Daten und das Bereinigen
Zwei Tabellen: year_orders.csv (Bestellung, Kunde, Zeit, Kanal, Status) und year_items.csv (die Zeilen der Bestellungen). Der Wert einer Bestellung ist die Summe ihrer Zeilen. Stornierte Bestellungen sind kein Umsatz:
Es gibt 11.116 Bestellungen, und ohne die 657 stornierten bleiben 10.459 übrig. Bevor du etwas rechnest, prüfst du das Bereinigen: Jede Bestellung hat einen Wert (0 fehlen), die kleinste beträgt 0,80 €, und die Tage sind 365, vom 1. November 2025 bis zum 31. Oktober 2026. Der Umsatz des Jahres beträgt 111.186,20 €, dieselbe Zahl wie im Modul „Daten aufbereiten“.
new, paid und shipped sind Bestellungen der letzten Woche, die noch unterwegs sind. Du zählst sie mit: Sie sind bezahlt oder werden es. Die Spalte channel sagt, woher die Bestellung kam: von der Website (web), aus der App (app) oder vom Telefon (phone).
Schreib das Bereinigen selbst. orders['ordered_at'] soll ein Datum sein, items['revenue'] der Umsatz jeder Zeile, orders['value'] der Wert jeder Bestellung und sold die Bestellungen ohne die stornierten.
Lösung
import pandas as pd
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
sold = orders[orders['status'] != 'cancelled']
print(len(sold), round(sold['value'].sum(), 2))sold behält 10.459 Bestellungen mit einem Umsatz von 111.186,20 €. Vergisst du den Filter, sind die 657 stornierten dabei, und jeder Monat sieht stärker aus, als er ist. Addierst du nur unit_price, verlierst du die Mengen: Zwei Brote zählen als eins.
Die Idee
Zuerst das Diagramm
Bevor du nach einer Ursache suchst, sieh dir den Einbruch im Kontext des Jahres an:
Der September liegt bei 7.128,80 € gegenüber 8.812,70 € im August: −19,1 %. Der Oktober liegt bei 9.920,40 €, der Einbruch geht also nicht weiter. Das Diagramm zeigt noch etwas anderes: Der Dezember liegt über 15.000 €, und Januar und Februar sind ebenfalls niedrig. Der September ist ein Einbruch mitten in normalen Monaten, kein Teil einer Saison.
Der September hat 30 Tage und der August 31. Könnte das einen Teil des Einbruchs erklären? Lege in per_day den Umsatz pro Tag für jeden Monat ab und in change die Änderung des Umsatzes pro Tag von August zu September in Prozent.
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
per_day = monthly / sold.groupby('month')['day'].nunique()
change = (per_day['2026-09'] / per_day['2026-08'] - 1) * 100
print(per_day.round(2))
print(round(change, 1))Pro Tag bringt der August 284,28 € und der September 237,63 €: −16,4 %. Ein Tag weniger erklärt etwa 3 Punkte der 19,1 %, der Rest ist ein echter Einbruch. Jeden Monat durch 30 zu teilen ist falsch: Der Februar hat 28 Tage und sieht schwächer aus, als er ist.
Die Idee
Ist es Rauschen?
Monate unterscheiden sich immer ein wenig. Bevor du nach einer Ursache suchst, prüfst du, ob der Einbruch größer ist als das übliche Rauschen. Du vergleichst die Tage: 31 Tage im August gegen 30 im September, mit dem Permutationstest aus der Lektion über Hypothesen. Unten siehst du die Änderung des Umsatzes pro Tag von Monat zu Monat:
Ein Septembertag bringt im Durchschnitt 46,65 € weniger. Von 10.000 Mischungen der Tage ergeben nur 74 einen mindestens so großen Unterschied: p = 0,0074. Die normalen Monate von März bis August bewegen sich zwischen −6,5 % und +5,8 %, und der September liegt bei −16,4 %. Der Februar (+16,8 %) ist die Rückkehr nach den Feiertagen, und der Oktober (+34,7 %) ist die Rückkehr nach dem September.
Der Einbruch ist kein Rauschen. Es lohnt sich, nach einer Ursache zu suchen.
Die Idee
Den Einbruch zerlegen
Der Umsatz ist die Anzahl der Bestellungen mal der Wert einer Bestellung. Die erste Zerlegung zeigt, welches von beiden fällt:
Die Bestellungen fallen von 854 auf 701 (−17,9 %), und die durchschnittliche Bestellung ist fast gleich: 10,32 € und 10,17 €. Der Warenkorb ist nicht kleiner; es gibt weniger Bestellungen. Rabatte sind nicht die Antwort.
Könnte es ein Produkt sein, etwa die Milchprodukte vom neuen Lieferanten? Der Umsatz nach Kategorie:
Jede Kategorie fällt, von −9,0 % (Obst und Gemüse) bis −34,3 % (Getränke). Die Geschenkkarte liegt bei +14,3 %, aber das sind 160 € gegen 140 € im August, ein paar Karten. Wäre ein Produkt schuld, wären die anderen Kategorien normal. Der Einbruch ist überall: Es liegt nicht am Produkt.
Liegt der Einbruch an einem Wochentag, etwa weil der Shop donnerstags geschlossen war? Lege in by_wd den durchschnittlichen Umsatz pro Tag für jeden Wochentag (Zeilen 0–6) für August und September (Spalten) ab und dazu eine Spalte change_% mit der Änderung in Prozent.
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
two = daily.loc['2026-08':'2026-09']
by_wd = two.groupby([two.index.dayofweek, two.index.strftime('%Y-%m')]).mean().unstack()
by_wd['change_%'] = (by_wd['2026-09'] / by_wd['2026-08'] - 1) * 100
print(by_wd.round(1))Der durchschnittliche Umsatz fällt an allen sieben Tagen: von −4,0 % am Montag bis −41,0 % am Donnerstag. Kein Tag ist „verschwunden“: Der Einbruch ist allgemein. Mit sum statt mean kommt der Mittwoch auf +18,0 %, aber nur, weil der September 5 Mittwoche hat und der August 4. Wenn die Tage in unterschiedlicher Zahl vorkommen, vergleichst du den Durchschnitt.
Die Idee
Der Kanal
Eine Spalte bleibt, die du noch nicht angesehen hast: channel. Der Umsatz nach Monat und Kanal:
Da ist es. Die Website (web) liegt im August bei 4.517,00 € und im September bei 4.660,80 €. Das Telefon (phone) bei 999,70 € und 1.028,70 €. Sie sind normal. Die App (app) fällt von 3.296,00 € auf 1.439,30 € und kommt im Oktober auf 3.755,40 € zurück.
Der Einbruch der App beträgt 1.856,70 € und ist damit größer als der ganze Einbruch des Shops (1.683,90 €): Die anderen beiden Kanäle wachsen sogar ein wenig. Deshalb fallen alle Kategorien und alle Tage gemeinsam: Die App verkauft alles, jeden Tag.
Wie viel des Umsatzes kommt in einem gewöhnlichen Monat aus der App? Lege in share den Anteil von app am Umsatz jedes Monats in Prozent ab.
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
table = sold.pivot_table(index='month', columns='channel', values='value', aggfunc='sum')
share = table['app'] / table.sum(axis=1) * 100
print(share.round(1))In den normalen Monaten bringt die App zwischen 34,0 % und 41,8 % des Umsatzes. Im September sind es 20,2 %, etwa die Hälfte, und im Oktober wieder 37,9 %. Der Anteil ist die bessere Zahl als die Summe: Saison und Anzahl der Tage bewegen ihn nicht, weil sie alle Kanäle gemeinsam bewegen.
Die Idee
Wann genau?
„September“ ist grob. Das App-Team wird nach einem Datum fragen. Die App-Bestellungen Tag für Tag, mit dem gleitenden 7-Tage-Durchschnitt, und die Tage um beide Enden:
Die Linie fällt Anfang September und kommt Anfang Oktober zurück. Die Tabelle zeigt die Enden. Am 2. September hat die App 9 Bestellungen, am 3. September nur 3 und am 5. September null, während die Website 25 und 15 hat. Am Ende: 6 Bestellungen am 1. Oktober, 8 am 2. Oktober und 12 am 3. Oktober.
Der gleitende Durchschnitt verschmiert die Enden um ein paar Tage, deshalb suchst du die Daten in den einzelnen Tagen. Der Beginn ist klar: 3. September. Das Ende ist unschärfer: Der 2. Oktober ist schon halb zurück, und ab dem 3. Oktober ist die App wie zuvor. Der Einbruch läuft vom 3. September bis zum 2. Oktober, plus/minus ein Tag.
Finde die Daten mit einer Regel, nicht nach Augenmaß. start ist der erste Tag ab dem 1. August 2026, an dem die App unter 20 % der Bestellungen des Tages hat. back ist der erste Tag nach start, an dem die App-Bestellungen mindestens dem Median der normalen Tage (bis zum 31. August) entsprechen.
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
share = counts['app'] / counts.sum(axis=1)
normal = counts.loc[:'2026-08-31', 'app']
start = share.loc['2026-08-01':][share.loc['2026-08-01':] < 0.2].index[0]
after = counts.loc[start:, 'app']
back = after[after >= normal.median()].index[0]
print(start.date(), back.date(), normal.median())start ist der 3. September 2026: An diesem Tag hat die App 10,3 % der Bestellungen. In den 304 Tagen vor dem September liegt der Anteil nur einmal unter 20 % (19. April 2026). back ist der 3. Oktober 2026 mit 12 Bestellungen: Der Median der normalen Tage ist 11, und vom 3. September bis zum 2. Oktober erreicht kein einziger Tag auch nur 10. Der Anteil des Tages ist ein besseres Signal als die Anzahl: Er fällt nicht, wenn der ganze Tag schwach ist.
Die Idee
Was hat der Einbruch gekostet?
Der Chef wird fragen, wie viel Geld es gekostet hat. Die naive Antwort ist „August minus September“: 1.683,90 €. Aber das nimmt an, der September hätte wie der August sein sollen. Der Oktober ist stärker als der August, also hätte auch der September stärker sein können.
Die Kanäle, die funktionieren, geben einen besseren Vergleich. In normalen Zeiten bringt die App einen stabilen Anteil im Verhältnis zur Website und zum Telefon. Dieser Anteil k ist ein Modell mit einer einzigen Zahl: Es sagt den Umsatz der App aus dem Umsatz der anderen beiden Kanäle an denselben Tagen voraus. Du lernst es aus den Tagen vor dem Einbruch und prüfst es an Tagen, die es nicht gesehen hat: dem Oktober nach dem Einbruch.
Für jeden Euro von der Website und vom Telefon bringt die App 0,641 €. Für den Oktober nach dem Einbruch sagt das Modell 3.688,92 €, und die Wahrheit sind 3.614,60 €: ein Fehler von 2,1 %. Für die Tage vom 3. September bis zum 2. Oktober erwartet das Modell 3.713,41 €, und die App hat 1.292,60 € gebracht. Der Verlust beträgt 2.420,81 €, 737 € mehr als beim naiven Vergleich.
Wie genau ist das? Der Anteil k über 30 Tage ist nicht immer derselbe. Mit dem Bootstrap ziehst du 10.000-mal 30 normale Tage und siehst, wie stark sich der Verlust bewegt:
Über 30 zufällige normale Tage liegt k zwischen 0,457 und 0,906. Das 95-%-Intervall für den Verlust reicht von 1.802,46 € bis 3.142,14 €. Im Bericht heißt das „etwa 2.400 €, zwischen 1.800 € und 3.150 €“. Die Schätzung ist grob, aber selbst die untere Grenze liegt über den naiven 1.683,90 €.
Genauer, für Neugierige
Das Modell app = k × (web + phone) hat ein Merkmal und eine Zahl, wie eine lineare Regression ohne Achsenabschnitt. Seine Prüfung ist wie im Modul über Modelle: Du lernst es an einigen Tagen (vor dem 3. September) und misst es an anderen (nach dem 2. Oktober). Es nimmt an, dass sich die Kanäle gemeinsam bewegen, und die Prüfung am Oktober bestätigt das. Wären App-Kunden im September zur Website gewechselt, würde das Modell den Verlust überschätzen: Es würde Bestellungen als verloren zählen, die tatsächlich über die Website kamen. Die Website liegt im September bei +3,2 % gegenüber dem August, innerhalb der üblichen Bewegung, also sind die meisten der verlorenen Bestellungen nicht woandershin gegangen.
Wie viele Bestellungen hat die App verloren? Baue dasselbe Modell für die Anzahl der Bestellungen: k ist app / (web + phone) an den Tagen vor dem 3. September, expected ist, wie viele App-Bestellungen es vom 3. September bis zum 2. Oktober gegeben hätte, actual, wie viele es waren, und lost_pct, welcher Teil der erwarteten verloren ging, in Prozent.
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
counts['rest'] = counts['web'] + counts['phone']
day = counts.index
before = day < '2026-09-03'
drop = (day >= '2026-09-03') & (day <= '2026-10-02')
k = counts.loc[before, 'app'].sum() / counts.loc[before, 'rest'].sum()
expected = k * counts.loc[drop, 'rest'].sum()
actual = counts.loc[drop, 'app'].sum()
lost_pct = (expected - actual) / expected * 100
print(round(expected, 1), actual, round(lost_pct, 1))Die App hätte etwa 368,6 Bestellungen gehabt, und sie hat 126. 65,8 % sind verloren, etwa zwei von drei. Multiplizierst du k mit den Bestellungen der App selbst, sagt das Modell den kaputten Kanal aus sich selbst voraus und sieht den Verlust nicht.
Die Idee
Der Bericht
Eine Analyse endet mit einer Geschichte, die jemand in einer Minute lesen kann. Drei Sätze, ein Diagramm, die Grenzen.
- Was: Der Umsatz im September liegt 19,1 % unter dem August, weil die App vom 3. September bis zum 2. Oktober etwa zwei Drittel weniger Bestellungen hatte; die Website und das Telefon sind normal, und ab dem 3. Oktober ist auch die App wieder normal.
- Wie viel: Der Verlust beträgt etwa 2.400 € (zwischen 1.800 € und 3.150 €), etwa 240 Bestellungen.
- Was tun: Prüft, was sich um den 3. September in der App geändert hat (ein Update, der Warenkorb, die Zahlung), und richtet einen Alarm ein, falls der Anteil eines Kanals fällt.
Die Grenzen. Die Daten sagen, wann und wo: in der App, ab dem 3. September. Warum, sagen sie nicht. Ein App-Update um den 3. September ist eine Hypothese, keine Tatsache: Das App-Team prüft sie mit den Warenkorb-Logs und den Zahlungsfehlern dieses Datums. Es könnte auch etwas anderes sein: der App-Store, ein abgelaufenes Zertifikat, eine Änderung beim Zahlungsanbieter. Das Diagramm kann diese Ursachen nicht unterscheiden. Deshalb sagt der Bericht „prüft“, nicht „das war es“.
Die Werbung, der Lieferant und die Preise sind nicht die Ursache. Keine der drei Ideen des Chefs hätte den Umsatz zurückgebracht.
Übung
Ein Alarm für das nächste Mal
Der Einbruch dauerte einen Monat, weil niemand den Kanal beobachtet hat. Eine Alarmregel soll den Einbruch schnell erkennen und in normalen Zeiten still bleiben. Einen Alarm, der jede Woche losgeht, beachtet niemand.
Baue den Alarm. week ist die Anzahl der Bestellungen pro Kanal über die letzten 7 Tage (rolling(7).sum()), app_share der Anteil von app daran, und alerts die Tage, an denen app_share unter 0.25 liegt. Wie viele Alarme gibt es vor dem 3. September, und wann ist der erste?
Lösung
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Stornierte Bestellungen zählen nicht zum Umsatz.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
week = counts.rolling(7).sum()
app_share = week['app'] / week[['web', 'app', 'phone']].sum(axis=1)
alerts = app_share[app_share < 0.25]
print(len(alerts.loc[:'2026-09-02']), alerts.index[0].date())
print(round(app_share.loc[:'2026-09-02'].min(), 3))Vor dem 3. September gibt es keinen einzigen Alarm: Der 7-Tage-Anteil liegt bei mindestens 0,308 (am 15. Juni 2026). Der erste Alarm kommt am 6. September, drei Tage nach dem Beginn, statt einen Monat später. Mit dem Anteil jedes Tages für sich hätte es 11 Fehlalarme gegeben, und mit einem Schwellenwert von 0,35 sogar 42 Tage mit Alarm. Den Schwellenwert wählst du aus den normalen Tagen: ein wenig unter dem niedrigsten Wert, den sie erreichen.
Die Idee
Wie es weitergeht
Der Kurs endet hier, und die Arbeit mit Daten geht weiter. Wohin:
- Notebooks. Mach dieses Projekt in JupyterLab auf deinem eigenen Computer: Zelle für Zelle, mit der Ausgabe darunter. Das Notebook ist zugleich der Bericht: Code, Diagramme und Text an einem Ort.
- Größere Daten. pandas hält alles im Speicher und arbeitet auf einem Kern. Polars hat dieselben Ideen (Gruppen, Joins, Daten), nutzt aber alle Kerne und liest Dateien, die größer sind als der Speicher:
pl.read_csv('year_orders.csv').group_by('channel').len()zählt die Bestellungen nach Kanal. - Dashboards. Der Alarm aus der Übung gehört am besten auf ein Dashboard, das sich von selbst aktualisiert. Streamlit und Dash machen aus einem Python-Skript ein Web-Dashboard mit Diagrammen und Filtern.
- Diagramme. seaborn und plotly sind Schichten über den Ideen von matplotlib: seaborn liefert statistische Diagramme in einer Zeile, plotly interaktive mit Zoom und Tooltips.
- Datenbanken. In einem echten Shop liegen die Bestellungen in einer Datenbank, nicht in einer CSV.
pd.read_sqlliest das Ergebnis einer SQL-Abfrage direkt in einen DataFrame. SQL lernst du im Kurs Datenbanken und SQL.
Der Weg bleibt derselbe: die Frage, die Daten, das Bereinigen, das Erkunden, der Schluss mit seinen Grenzen und die Geschichte.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Konfidenzintervalle · Eine Hypothese testen · Korrelation und Ursache · Modelle: aus Beispielen lernen
- Aufgaben zum Thema: Projekt: Warum der Umsatz gefallen ist