KI gegen Warenkorbabbrüche und Retouren

Kurz gesagt

Niveau: Profi

Warenkorbabbrüche und Retouren sind Vorhersageprobleme, nicht nur Designprobleme.

  • Abbruch: Ein Gradient Boosting Modell bewertet jede Checkout Sitzung und wählt die Reaktion, die zum Grund passt, auf der Seite, bevor die Person geht. Beurteile es nach Precision und Recall, nie nach der Trefferquote.
  • Mails zur Rückgewinnung sind Werbung: Einwilligung oder die Ausnahme für Bestandskunden nach § 174 Abs. 4 TKG 2021, mit der Möglichkeit zur Ablehnung bei der Erhebung und in jeder Mail und dem Abgleich mit der ECG Liste. Auch eine Feedbackbitte per Mail ist keine reine Servicenachricht.
  • Retouren: Bewerte jede Position bei der Bestellung (Daten nach der Zeit trennen), schlag Größen vor, die ähnliche Kunden behalten haben, und repariere die Produkte, die immer wieder zurückkommen.
  • Die Rechnung für einen erfundenen Shop mit € 210.000 Umsatz im Monat: rund € 5.600 Marge im Monat aus zwei Punkten weniger Abbruch und rund € 3.600 im Monat aus drei Punkten weniger Retouren.

Sieben von 10 Warenkörben werden verlassen, und ein großer Teil dessen, was verkauft wird, kommt zurück. Ich baue zwei Vorhersagesysteme von Grund auf, einen Scorer für das Abbruchrisiko und einen Prädiktor für das Retourenrisiko, in Python mit der Solidus Seite in Rails, und rechne die Wirtschaftlichkeit an einem erfundenen Innsbrucker Shop für Outdoorbekleidung durch.

Zwei Lecks, und beide sind Vorhersageprobleme

Zwei Zahlen beschreiben die undichtesten Stellen fast jedes Onlineshops.

Die erste ist der Warenkorbabbruch. Das Baymard Institute führt einen laufenden Durchschnitt veröffentlichter Studien, und über 50 Studien von 2006 bis 2025 liegt der dokumentierte Mittelwert bei 70,22 %.1 Ungefähr sieben von 10 Menschen, die etwas in den Warenkorb legen, gehen, ohne zu bezahlen.

Die zweite sind Retouren. In den USA haben die National Retail Federation und Happy Returns im Dezember 2024 geschätzt, dass Händler für dieses Jahr mit der Rücksendung von 16,9 % ihres Jahresumsatzes rechneten, zusammen rund 890 Milliarden US Dollar.2 Im selben Bericht sagten 51 % der Käuferinnen und Käufer der Generation Z, dass sie mehrere Artikel bestellen und von vornherein einen Teil zurückschicken wollen. Im Handel heißt das Bracketing.2 Das sind Zahlen aus den USA. Nimm sie als Gefühl für die Größenordnung, nicht als Prognose für deinen Shop.

Beide Lecks sehen nach Conversion Problemen aus. Ich halte sie für besser behandelt als Vorhersageprobleme. Die Signale, dass jemand gleich geht, stecken schon in deinen Checkout Events. Die Signale, dass eine Bestellung zurückkommt, stecken schon in deiner Bestellhistorie. Die Arbeit besteht darin, die Frage zu stellen, bevor es passiert, nicht danach.

Dieser Artikel baut zwei Systeme von Grund auf: einen Scorer für das Abbruchrisiko, der Kundinnen und Kunden kurz vor dem Gehen erkennt und die passende Reaktion wählt, und einen Prädiktor für das Retourenrisiko, der Größenprobleme und Produkte mit vielen Rücksendungen markiert, bevor das Paket rausgeht. Die Modelle sind in Python, die Shopseite ist Ruby on Rails und Solidus. Alles läuft an einem erfundenen Shop, keine Beispiele von Kunden. Wenn dir die Begriffe Klassifikator, Wahrscheinlichkeitsscore oder Trainingsdaten neu sind, sind KI vs Machine Learning und logistische Regression und Lift Charts der sanfte Einstieg.

Der Beispielshop für diesen Artikel

Karwendler ist ein erfundener Shop für Outdoorbekleidung in Innsbruck, unter karwendler.example, und läuft auf Solidus. Jede Zahl über Karwendler in diesem Artikel ist für das Beispiel erfunden und so gekennzeichnet. Jacken, Fleecepullover und Wanderhosen in den Größen XS bis XXL, die Größe spielt also eine Rolle.

Warum Menschen abbrechen, und warum das vorhersagbar ist

Baymard fragt Kundinnen und Kunden in den USA, die in den letzten drei Monaten eine Bestellung abgebrochen haben, nach dem Grund. 42 % sagen, sie hätten nur gestöbert. Lässt du diese weg, führen bei den übrigen zu hohe Zusatzkosten (40 %), eine zu langsame Lieferung (20 %), fehlendes Vertrauen in die Seite bei der Eingabe der Kartendaten (19 %), der Zwang, ein Konto anzulegen (18 %), ein zu langer oder komplizierter Checkout (17 %), unbefriedigende Rückgabebedingungen (13 %) und zu wenige Zahlungsarten (9 %).1 Mehrere Gründe waren möglich, deshalb ergeben die Anteile zusammen mehr als 100 %. Wenn dich die Psychologie des Checkouts selbst interessiert, geht Conversion und UX Psychologie darauf ein.

Und hier wird es zu einem Problem für Machine Learning, nicht nur für das Design. Verschiedene Menschen gehen aus verschiedenen Gründen, und ihr Verhalten verrät oft, aus welchem. Wer dreimal zwischen Zahlungsarten wechselt, hat ein Zahlungsproblem. Wer lange auf dem Lieferschritt sitzt, hat ein Problem mit den Versandkosten oder dem Liefertermin. Wessen Rabattcode gerade abgelehnt wurde, hat ein Preisproblem.

Eines siehst du nicht, egal was manche Anleitungen behaupten: ob jemand gegangen ist, um den Preis bei der Konkurrenz zu prüfen. Der Browser kann einer Seite über die Page Visibility API mitteilen, dass ihr Tab ausgeblendet wurde, aber nicht, wohin die Person gegangen ist. Ein ausgeblendeter Tab ist ein schwaches Signal, kein Grund.

Die Abbruchquote selbst

Baymard definiert die Quote als eins minus abgeschlossene Käufe geteilt durch angelegte Warenkörbe:1

A=(1−ck)×100A = \left(1 - \frac{c}{k}\right) \times 100

Dabei ist AA die Abbruchquote in Prozent, cc die Zahl der abgeschlossenen Bestellungen und kk die Zahl der im selben Zeitraum angelegten Warenkörbe. Es ist eine einfache Quote, Ereignisse geteilt durch Gelegenheiten, nur umgedreht. Grundlagen: Verhältnisse und Quoten

Beispiel: die Abbruchquote von Karwendler

Im Beispielmonat legen die Kundinnen und Kunden von Karwendler 10.000 Warenkörbe an und schließen 3.000 Bestellungen ab. Also ist A=(1−3.000/10.000)×100=(1−0,3)×100=70 %A = (1 - 3{.}000 / 10{.}000) \times 100 = (1 - 0{,}3) \times 100 = 70\,\%.

Karwendler liegt genau auf dem dokumentierten Durchschnitt. Normal ist trotzdem teuer, auch wenn es sich nach "passt eh" anfühlt.

Eine Checkout Sitzung nach Abbruchrisiko bewerten

Das Modell unten ist ein Gradient Boosting Klassifikator: viele kleine Entscheidungsbäume, von denen jeder die Fehler der vorherigen korrigiert, und die zusammen eine Wahrscheinlichkeit zwischen 0 und 1 dafür ausgeben, dass eine Checkout Sitzung mit einem Abbruch endet.3 Es lernt aus vergangenen Sitzungen, deren Ausgang du kennst.

import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, roc_auc_score, precision_recall_curve
import joblib

FEATURES = [
    'cart_value', 'cart_item_count',
    'time_on_checkout_seconds', 'payment_method_switches',
    'items_removed_during_checkout', 'pages_viewed_before_checkout',
    'is_returning_customer', 'previous_abandonment_count',
    'device_type', 'hour_of_day', 'delivery_page_time_seconds',
    'promo_frustration', 'avg_time_per_item', 'hesitation_score',
]

def add_features(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df['avg_time_per_item'] = df['time_on_checkout_seconds'] / df['cart_item_count'].clip(lower=1)
    df['hesitation_score'] = df['payment_method_switches'] + df['items_removed_during_checkout'] * 2
    df['promo_frustration'] = ((df['promo_code_attempted'] == 1) & (df['promo_code_valid'] == 0)).astype(int)
    return df

def train_abandonment_model(sessions_csv: str):
    """
    Eine Zeile pro Checkout Sitzung, aus deinen eigenen serverseitigen Checkout Events:
    cart_value, cart_item_count, time_on_checkout_seconds, payment_method_switches,
    items_removed_during_checkout, pages_viewed_before_checkout, is_returning_customer,
    previous_abandonment_count, device_type (mobil 1, Desktop 0), hour_of_day,
    delivery_page_time_seconds, promo_code_attempted, promo_code_valid,
    abandoned (die Zielgröße: 1 abgebrochen, 0 abgeschlossen)
    """
    df = add_features(pd.read_csv(sessions_csv))
    X, y = df[FEATURES], df['abandoned']
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42, stratify=y
    )

    model = GradientBoostingClassifier(
        n_estimators=200, max_depth=5, learning_rate=0.1,
        subsample=0.8, min_samples_leaf=20
    )
    model.fit(X_train, y_train)

    y_proba = model.predict_proba(X_test)[:, 1]
    print(classification_report(y_test, model.predict(X_test)))
    print(f"AUC: {roc_auc_score(y_test, y_proba):.3f}")
    for feat, imp in sorted(zip(FEATURES, model.feature_importances_), key=lambda x: x[1], reverse=True):
        print(f"  {feat}: {imp:.3f}")

    # precision und recall haben einen Eintrag mehr als thresholds, also den letzten weglassen
    precision, recall, thresholds = precision_recall_curve(y_test, y_proba)
    f1 = 2 * precision[:-1] * recall[:-1] / (precision[:-1] + recall[:-1] + 1e-8)
    threshold = thresholds[np.argmax(f1)]
    print(f"Intervention threshold: {threshold:.3f}")

    joblib.dump(model, 'abandonment_model.pkl')
    return model, threshold

def score_live_session(model, session: dict, threshold: float) -> dict:
    features = add_features(pd.DataFrame([session]))[FEATURES]
    p = float(model.predict_proba(features)[0][1])
    return {
        'abandonment_probability': round(p, 3),
        'risk_level': 'high' if p > threshold else 'medium' if p > threshold * 0.7 else 'low',
        'should_intervene': p > threshold,
        'suggested_intervention': suggest_intervention(features.iloc[0], p),
    }

def suggest_intervention(f, p: float) -> str:
    if f['promo_frustration']:
        return 'offer_alternative_discount'
    if f['delivery_page_time_seconds'] > 30:
        return 'highlight_delivery_options'
    if f['payment_method_switches'] > 2:
        return 'show_payment_help'
    if f['is_returning_customer']:
        return 'gentle_reminder'  # treue Kunden nicht darauf trainieren, auf einen Rabatt zu warten
    if f['cart_value'] > 100:
        return 'offer_free_delivery'
    return 'exit_intent_message'

Zwei Korrekturen gegenüber der Fassung dieses Codes, die ich zuerst veröffentlicht hatte. Die Arrays für Precision und Recall aus scikit learn sind einen Eintrag länger als das Array der Schwellenwerte, deshalb muss die Suche nach dem Schwellenwert den letzten Eintrag weglassen, sonst kann sie einen Index wählen, den es nicht gibt.4 Und die Bewertung live baut jetzt dieselben abgeleiteten Merkmale wie das Training, aus einer einzigen Funktion, damit das Modell nie eine Spalte sieht, auf die es nicht trainiert wurde.

Welche Merkmale oben landen, hängt von deinem Shop ab. Gib die Wichtigkeiten aus und lies sie, und nimm niemandes Liste als Gesetz, meine eingeschlossen.

Die Trefferquote ist hier die falsche Zahl

Wenn 70 % der Sitzungen mit einem Abbruch enden, liegt ein "Modell", das jedes Mal "bricht ab" sagt, zu 70 % richtig und ist nutzlos. Das ist das Problem der Basisrate. Grundlagen: Indexwerte und Basisraten Deshalb gibt der Code stattdessen Precision, Recall und deren Ausgleich aus, den F1 Wert:

P=TPTP+FP,R=TPTP+FN,F1=2PRP+RP = \frac{TP}{TP + FP}, \qquad R = \frac{TP}{TP + FN}, \qquad F_1 = \frac{2PR}{P + R}

Dabei ist PP die Precision, der Anteil der markierten Sitzungen, die wirklich abbrechen, RR der Recall, der Anteil der tatsächlichen Abbrecher, die das Modell markiert, TPTP die Zahl der richtig Positiven (markiert und abgebrochen), FPFP die falsch Positiven (markiert, aber bezahlt), FNFN die falsch Negativen (abgebrochen, aber nicht markiert), und F1F_1 ist das harmonische Mittel aus PP und RR. Precision und Recall sind Quoten, Ereignisse geteilt durch Gelegenheiten. Grundlagen: Verhältnisse und Quoten

Beispiel: taugt das Modell von Karwendler etwas?

Die Testdaten von Karwendler umfassen 1.000 Sitzungen, davon 700 mit Abbruch. Beim gewählten Schwellenwert markiert das Modell 600 Sitzungen, und 540 davon brechen wirklich ab.

  • TP=540TP = 540, FP=600−540=60FP = 600 - 540 = 60, FN=700−540=160FN = 700 - 540 = 160.
  • Precision: P=540/600=0,90P = 540 / 600 = 0{,}90.
  • Recall: R=540/700=0,771R = 540 / 700 = 0{,}771.
  • F1=2×0,90×0,7710,90+0,771=1,3881,671=0,83F_1 = \frac{2 \times 0{,}90 \times 0{,}771}{0{,}90 + 0{,}771} = \frac{1{,}388}{1{,}671} = 0{,}83.

In einfachen Worten: Neun von 10 Menschen, die eine Intervention sehen, waren wirklich kurz davor zu gehen, und das Modell erwischt rund drei Viertel aller, die gehen. Die 60 falsch Positiven sind Leute, die sowieso bezahlt hätten. Deshalb sollten die Interventionen helfen und keine Rabatte sein. Ein Rabatt für jemanden, der ohnehin zahlen wollte, ist verschenkte Marge.

Die Reaktion auf der Shopseite wählen

Das Python Modell bewertet. Die Rails Seite entscheidet, was wann angezeigt wird. Ich rufe das Modell über einen kleinen Service Wrapper mit Timeout und Rückfallebene auf, das Muster, das ich in KI in bestehende Workflows integrieren beschreibe, damit ein langsames Modell nie einen Checkout aufhält.

module CartRecovery
  class InterventionEngine
    INTERVENTIONS = {
      offer_alternative_discount: { type: :inline, template: 'checkout/interventions/discount_offer' },
      highlight_delivery_options: { type: :inline, template: 'checkout/interventions/delivery_options' },
      show_payment_help:          { type: :inline, template: 'checkout/interventions/payment_help' },
      gentle_reminder:            { type: :inline, template: 'checkout/interventions/saved_basket' },
      offer_free_delivery:        { type: :banner, template: 'checkout/interventions/free_delivery' },
      exit_intent_message:        { type: :exit_intent, template: 'checkout/interventions/exit_intent' }
    }.freeze

    def evaluate(session:, order:)
      return if session.intervention_shown?

      features = SessionFeatureBuilder.build(session, order)
      risk = ScoringService.abandonment(features, timeout: 0.3) # nil bei Timeout oder Fehler
      return unless risk && risk[:should_intervene]

      intervention = INTERVENTIONS.fetch(risk[:suggested_intervention].to_sym)
      session.record_intervention!(intervention, risk)
      intervention
    end
  end

  class SessionFeatureBuilder
    def self.build(session, order)
      user = order.user
      {
        cart_value: order.total.to_f,
        cart_item_count: order.line_items.sum(:quantity),
        time_on_checkout_seconds: session.checkout_duration_seconds,
        payment_method_switches: session.payment_switches_count,
        items_removed_during_checkout: session.removals_during_checkout,
        pages_viewed_before_checkout: session.page_count,
        is_returning_customer: user && user.orders.complete.exists? ? 1 : 0,
        previous_abandonment_count: user ? session.previous_abandonments_for(user) : 0,
        device_type: session.mobile? ? 1 : 0,
        hour_of_day: Time.current.hour,
        delivery_page_time_seconds: session.time_on_step(:delivery),
        promo_code_attempted: session.promo_attempted? ? 1 : 0,
        promo_code_valid: session.promo_valid? ? 1 : 0
      }
    end
  end
end

order.total, order.line_items und user.orders.complete sind Standard in Solidus. Das Objekt session ist dein eigenes: ein kleines Modell, das Checkout Events auf dem Server aufzeichnet. Von dieser Engine geht keine Mail raus. Alles, was sie tut, passiert auf der Seite, solange die Person noch da ist.

Beispiel: drei Kundinnen und Kunden bei Karwendler

Drei Sitzungen überschreiten am selben Nachmittag den Schwellenwert.

  1. Ein Erstbesucher mit einer Jacke für € 140, dessen Code "WINTER10" gerade abgelehnt wurde. promo_frustration ist 1, also bietet die Engine den Rabatt an, der tatsächlich läuft, falls es einen gibt, und sagt klar, dass der alte Code abgelaufen ist.
  2. Eine Kundin, die mit einem Fleecepullover für € 60 seit 45 Sekunden auf dem Lieferschritt sitzt. Die Engine zeigt die Versandoptionen und Liefertermine nebeneinander, mit der Grenze für kostenlosen Versand, falls Karwendler eine hat.
  3. Eine Stammkundin mit acht Bestellungen, die dreimal die Zahlungsart gewechselt hat. Die Engine zeigt Hilfe zur Zahlung. Keinen Rabatt: Sie vertraut Karwendler schon, im Weg steht ihr der Zahlungsschritt.

Mails zur Rückgewinnung, und die Erlaubnis, die sie brauchen

Wird ein Warenkorb trotzdem verlassen, kann eine Mailstrecke zur Rückgewinnung einen Teil davon zurückholen. Die Signale des Modells entscheiden, welche Strecke.

Vor dem Timing kommt die Erlaubnis. Eine Erinnerung an den Warenkorb bewirbt deine Produkte, sie ist also Werbung per Mail, und in Österreich regelt das das Telekommunikationsgesetz 2021 (TKG 2021). Nach § 174 Abs. 3 TKG 2021 ist eine Mail, auch eine SMS, zu Zwecken der Direktwerbung ohne vorherige Einwilligung des Empfängers unzulässig. Die Ausnahme für Bestandskunden in § 174 Abs. 4 TKG 2021 greift nur, wenn du die Kontaktdaten im Zusammenhang mit einem Verkauf oder einer Dienstleistung an deine Kunden bekommen hast, für eigene ähnliche Produkte oder Dienstleistungen wirbst, die Person bei der Erhebung und zusätzlich bei jeder Mail klar und deutlich die Möglichkeit bekommen hat, das kostenfrei und problemlos abzulehnen, und sie die Zusendung nicht von vornherein abgelehnt hat, insbesondere nicht durch Eintragung in die Liste nach § 7 Abs. 2 des E Commerce Gesetzes (ECG).5 Diese Liste gleichst du also vor jedem Versand ab. Das Gesetz spricht vom Verkauf, nicht von Verhandlungen über einen Verkauf. Für einen Warenkorb, der nie bezahlt wurde, ist die Ausnahme deshalb eine unsichere Grundlage.

Zwei praktische Folgen. Die Möglichkeit, abzulehnen, gehört in das Checkout Formular, dort, wo die Mailadresse erhoben wird, nicht nur in die Mail. Und eine Erinnerung, die als "kurze Frage zu deinem Warenkorb" daherkommt, bleibt Werbung, wenn ihr Zweck der Verkauf ist. Hier ist die Linie strenger, als viele glauben. Die deutlichste Entscheidung zu genau dieser Frage kommt aus Deutschland: Der Bundesgerichtshof hat 2018 entschieden, dass auch eine Kundenzufriedenheitsbefragung per Mail Werbung ist, selbst wenn sie zusammen mit der Rechnung kommt.6 Österreichische Behörden und Gerichte bindet das nicht. Der Oberste Gerichtshof legt den Begriff "zu Zwecken der Direktwerbung" aber ohnehin weit aus: Er erfasst jede Maßnahme, die auf ein Bedürfnis und die Möglichkeit seiner Befriedigung hinweist, schon die Anregung, bestimmte Leistungen in Anspruch zu nehmen, und die Aufmachung als Newsletter oder Informationsmail ändert an der Einordnung als Werbung nichts.7 Und die deutsche Regel, auf die sich der Bundesgerichtshof stützt, setzt wie das österreichische Verbot unerbetener Werbemails dieselbe EU Vorgabe um, die ePrivacy Richtlinie 2002/58/EG.8 Die sichere Linie für einen österreichischen Shop: Auch eine schlichte Bitte um Feedback ohne Angebot ist keine reine Servicenachricht und geht per Mail nur an Menschen, die eingewilligt haben oder unter die Ausnahme für Bestandskunden fallen. Ich habe diese Grenze im oben verlinkten Artikel über Marketingautomatisierung genauer beschrieben.

Die Strecke startet mit einem Solidus Event. Solidus hat einen Bus zum Veröffentlichen und Abonnieren, Spree::Bus, gebaut auf der Bibliothek Omnes. Er veröffentlicht Events wie order_finalized, und du kannst eigene registrieren, am besten mit einem Namen samt Präfix, damit er nie mit einem künftigen Event aus dem Kern kollidiert.9 Abonnenten sollen den Ablauf, der das Event veröffentlicht hat, nicht aufhalten, deshalb stellen sie nur Jobs in die Warteschlange.9

# config/initializers/omnes.rb
Rails.application.config.to_prepare do
  Spree::Bus.register(:karwendler_cart_abandoned) unless Spree::Bus.registry.registered?(:karwendler_cart_abandoned)
  Karwendler::CartRecoverySubscriber.new.subscribe_to(Spree::Bus)
end

# Ein wiederkehrender Job veröffentlicht das Event für Warenkörbe mit Mailadresse und einer Stunde ohne Aktivität:
# Spree::Bus.publish(:karwendler_cart_abandoned, order: order, context: context)

# app/subscribers/karwendler/cart_recovery_subscriber.rb
module Karwendler
  class CartRecoverySubscriber
    include Omnes::Subscriber

    handle :karwendler_cart_abandoned, with: :schedule

    def schedule(event)
      CartRecovery::EmailSequencer.new.schedule(order: event.payload[:order], context: event.payload[:context])
    end
  end
end

module CartRecovery
  class EmailSequencer
    SEQUENCES = {
      price_sensitive:    [{ wait: 1.hour, template: :reminder }, { wait: 24.hours, template: :current_offers }],
      delivery_concerned: [{ wait: 1.hour, template: :delivery_options }],
      returning_customer: [{ wait: 2.hours, template: :saved_basket }],
      default:            [{ wait: 1.hour, template: :reminder }, { wait: 24.hours, template: :reviews_and_fit }]
    }.freeze

    def schedule(order:, context:)
      # Eigener Nachweis des Shops: Einwilligung, oder Bestandskunde ohne Ablehnung und nicht auf der ECG Liste
      return unless MarketingPermission.email_allowed?(order.email)

      key = sequence_for(context)
      SEQUENCES.fetch(key).each_with_index do |step, i|
        RecoveryEmailJob.set(wait: step[:wait]).perform_later(
          order_id: order.id, template: step[:template], position: i + 1, sequence: key.to_s
        )
      end
    end

    private

    def sequence_for(context)
      return :price_sensitive if context[:promo_frustration] == 1
      return :delivery_concerned if context[:delivery_page_time_seconds].to_i > 30
      return :returning_customer if context[:is_returning_customer] == 1

      :default
    end
  end
end

class RecoveryEmailJob < ApplicationJob
  def perform(order_id:, template:, position:, sequence:)
    order = Spree::Order.find(order_id)
    return if order.completed?                                  # zurückgekommen und bezahlt
    return unless MarketingPermission.email_allowed?(order.email) # vielleicht inzwischen abgelehnt
    CartRecoveryMailer.with(order: order, template: template, position: position).reminder.deliver_now
  end
end

Drei Dinge haben sich gegenüber der ersten Fassung geändert. Die Prüfung der Erlaubnis nutzt den eigenen Einwilligungsnachweis des Shops und funktioniert über order.email auch bei Gastbestellungen. Jeder Job prüft vor dem Versand noch einmal, weil die Person inzwischen bezahlt oder abgelehnt haben kann. Und die eskalierende Stufe "letzte Chance, 10 % Rabatt" ist weg. Künstliche Dringlichkeit macht keinen guten Eindruck, und ein Rabatt in der dritten Mail bringt Stammkunden bei, absichtlich abzubrechen.

Beispiel: welche Warenkörbe bei Karwendler eine Mail bekommen

An einem Dienstag werden 300 Warenkörbe bei Karwendler eine Stunde lang still. 120 haben keine Mailadresse, weil die Person nie bis zu diesem Schritt kam. Von den 180 mit Adresse gehören 110 Menschen, die eingewilligt haben oder schon gekauft, nie abgelehnt haben und nicht auf der ECG Liste stehen. Diese 110 bekommen eine Strecke. Die anderen 70 bekommen nichts, und der richtige Ort, um einige davon zurückzugewinnen, ist der Checkout selbst. Genau dafür ist die Engine auf der Seite da.

Warum Retouren entstehen, und welche du verhindern kannst

Nicht jede Retoure ist ein Fehler. Ein Geschenk, das nicht passt, gehört zum Einkaufen dazu, und großzügige Rückgabebedingungen sind einer der Gründe, warum Menschen überhaupt online kaufen. Aber manche Retouren lassen sich vorhersagen, bevor das Paket den Hof verlässt.

Bei Kleidung fang mit Größe und Passform an. Umfragen gehen weit auseinander, wie groß dieser Anteil ist, also zählt deine eigene Zahl: Strukturierte Retourengründe sagen sie dir innerhalb eines Monats. Danach kommt die enttäuschte Erwartung, "sah online anders aus", die bessere Produktdaten und Fotos beheben, und die mit wie generative KI die Produktentdeckung verändert zusammenhängt. Dann das Bracketing, das du im Moment der Bestellung erkennen kannst.

Vorhersagen, welche Bestellungen zurückkommen

Dieses Modell bewertet jede Bestellposition im Moment der Bestellung. Eine Änderung gegenüber der ersten Fassung ist sehr wichtig: Trainings und Testdaten werden nach der Zeit getrennt, nicht zufällig. Bei einer zufälligen Trennung landen Positionen derselben Bestellung oder derselben Kundin auf beiden Seiten, und das Modell sieht auf dem Papier besser aus, als es im echten Betrieb sein wird.

import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import classification_report, roc_auc_score
import joblib

RETURN_FEATURES = [
    'size_risk', 'product_avg_return_rate', 'customer_return_rate', 'customer_order_count',
    'order_value', 'order_item_count', 'is_bracketing', 'high_return_product',
    'high_return_customer', 'review_sizing_flag', 'days_since_last_order', 'is_sale_item',
]

def add_return_features(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df['is_bracketing'] = df['same_product_multiple_sizes']
    df['size_risk'] = df['size_deviation'].abs()        # Index der bestellten Größe minus Index der üblichen Größe
    df['high_return_product'] = (df['product_avg_return_rate'] > 0.25).astype(int)
    df['high_return_customer'] = (df['customer_return_rate'] > 0.30).astype(int)
    df['review_sizing_flag'] = (df['product_review_mentions_sizing_issue'] > 3).astype(int)
    return df

def train_return_risk_model(lines_csv: str):
    """
    Eine Zeile pro Bestellposition: order_date, size_deviation, product_avg_return_rate,
    product_review_mentions_sizing_issue, customer_return_rate, customer_order_count,
    order_value, order_item_count, same_product_multiple_sizes, days_since_last_order,
    is_sale_item, was_returned (die Zielgröße: 1 zurückgeschickt, 0 behalten)
    """
    df = add_return_features(pd.read_csv(lines_csv, parse_dates=['order_date']))
    df = df.sort_values('order_date')
    cut = int(len(df) * 0.8)                     # auf den älteren 80 % trainieren, auf den neuesten 20 % testen
    train, test = df.iloc[:cut], df.iloc[cut:]

    model = GradientBoostingClassifier(n_estimators=200, max_depth=5, learning_rate=0.1, subsample=0.8)
    model.fit(train[RETURN_FEATURES], train['was_returned'])

    y_proba = model.predict_proba(test[RETURN_FEATURES])[:, 1]
    print(f"AUC: {roc_auc_score(test['was_returned'], y_proba):.3f}")
    print(classification_report(test['was_returned'], model.predict(test[RETURN_FEATURES])))
    joblib.dump(model, 'return_risk_model.pkl')
    return model

Die Retourenquoten pro Produkt und pro Kunde dürfen nur aus Bestellungen vor dem Bestelldatum der jeweiligen Position berechnet werden. Fließen spätere Retouren ein, liest das Modell die Antwort aus der Zukunft. Das ist der häufigste Weg, auf dem ein Retourenmodell im Test glänzt und danach enttäuscht.

Das Merkmal Zahlungsart aus der ersten Fassung habe ich gestrichen, es kam mit einer unbelegten Behauptung über Kunden, die später bezahlen. Wenn du vermutest, dass die Zahlungsart eine Rolle spielt, nimm sie auf und lass deine eigenen Daten sagen, ob es so ist.

Nutze den Score, um der Kundin bei einer guten Wahl zu helfen, nie, um Menschen heimlich schlechter zu behandeln. Ein Größenvorschlag hilft allen. Eine versteckte Strafe für "Kunden mit vielen Retouren" ist eine andere Art von Entscheidung, und eine, die ich nicht automatisieren würde.

Beispiel: eine Bestellung bei Karwendler

Eine Kundin bestellt die Regenjacke Dachstein in M und in L. Ihre übliche Größe, aus vier behaltenen Bestellungen, ist M. Die Retourenquote der Jacke liegt bisher bei 31 %, und fünf Bewertungen sagen, dass sie klein ausfällt.

  • is_bracketing ist 1 (zwei Größen desselben Produkts).
  • size_risk ist 0 für die M und 1 für die L.
  • high_return_product ist 1, weil 0,31>0,250{,}31 > 0{,}25.
  • review_sizing_flag ist 1, weil 5>35 > 3.

Das Modell bewertet beide Positionen über 0,6 (für das Beispiel erfundene Scores), eine der beiden Jacken kommt also sehr wahrscheinlich zurück. Der Größenempfehler im nächsten Abschnitt schlägt ihr L vor. Vor dem Versand schickt Karwendler eine schlichte Nachricht: "Diese Jacke fällt klein aus. Die meisten, die sonst M tragen, haben die L behalten." Das ist die Information, die sie gebraucht hat, und wenn sie die M streicht, macht ein Paket die Rückreise nie.

Eine Größe aus Menschen wie der Kundin empfehlen

Dieselbe Idee hinter "Kunden, die das gekauft haben, kauften auch" aus KI Empfehlungen in Solidus bauen kann eine Größe vorschlagen: Finde frühere Kundinnen und Kunden, die dieser Person ähneln und dieses Produkt behalten haben, und lass sie abstimmen.

import numpy as np
import pandas as pd
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler

PROFILE = ['height_cm', 'weight_kg', 'usual_size_tops_index', 'usual_size_bottoms_index']

def build_size_recommender(history_csv: str):
    """
    Eine Zeile pro Kauf: user_id, product_id, size_ordered, was_returned,
    dazu die optionalen Profilfelder oben (Größen als Zahlen: XS 0, S 1, M 2 und so weiter).
    """
    kept = pd.read_csv(history_csv).query('was_returned == 0')

    def recommend(product_id: int, profile: dict, k: int = 20) -> dict:
        rows = kept[kept['product_id'] == product_id].dropna(subset=PROFILE)
        if len(rows) < 10 or any(profile.get(f) is None for f in PROFILE):
            return {'recommendation': None, 'confidence': 'insufficient_data'}

        scaler = StandardScaler().fit(rows[PROFILE])   # sonst übertönen die Zentimeter die Größen
        X = scaler.transform(rows[PROFILE])
        user = scaler.transform(pd.DataFrame([profile])[PROFILE])

        nn = NearestNeighbors(n_neighbors=min(k, len(rows))).fit(X)
        distances, idx = nn.kneighbors(user)
        weights = 1 / (distances[0] + 1e-6)

        votes = {}
        for size, w in zip(rows.iloc[idx[0]]['size_ordered'], weights):
            votes[size] = votes.get(size, 0) + w
        best = max(votes, key=votes.get)
        share = votes[best] / sum(votes.values())
        return {
            'recommendation': best,
            'confidence': 'high' if share > 0.6 else 'medium' if share > 0.4 else 'low',
            'confidence_score': round(share, 3),
        }

    return recommend

Die erste Fassung hat rohe Zentimeter, Kilogramm und Größencodes direkt in die Abstandsberechnung gegeben. Die Körpergröße in Zentimetern schwankt um Dutzende, ein Größenindex um eins oder zwei, also hat die Körpergröße fast alles entschieden. Die Dokumentation von scikit learn warnt selbst, dass sich viele Schätzer schlecht verhalten, wenn Merkmale auf sehr unterschiedlichen Skalen liegen, und genau das behebt die Zeile mit StandardScaler.10 Außerdem habe ich die Rückfallebene entfernt, die die beliebteste Größe empfahl, wenn eine Kundin kein Profil hatte. Das ist keine Empfehlung, das ist ein Raten mit selbstsicherem Gesicht.

Körpergröße und Gewicht sind personenbezogene Daten, und die Datenschutz Grundverordnung (DSGVO) verlangt, dass du nur erhebst, was dem Zweck angemessen, erheblich und auf das notwendige Maß beschränkt ist.11 Mach die Felder also optional, sag, warum du fragst, und nutze sie für nichts anderes.

Beispiel: eine Abstimmung über die Größe mit drei Nachbarn

Damit die Rechnung sichtbar bleibt, nimm drei nächste Nachbarn statt 20. Nach der Skalierung liegen ihre Abstände zur Kundin bei 0,5 (hat M behalten), 1,0 (hat M behalten) und 0,8 (hat L behalten).

  • Die Gewichte sind eins durch den Abstand: 1/0,5=2,01/0{,}5 = 2{,}0, 1/1,0=1,01/1{,}0 = 1{,}0, 1/0,8=1,251/0{,}8 = 1{,}25.
  • Stimmen: M bekommt 2,0+1,0=3,02{,}0 + 1{,}0 = 3{,}0, L bekommt 1,251{,}25. Zusammen 4,254{,}25.
  • Anteil für M: 3,0/4,25=0,7063{,}0 / 4{,}25 = 0{,}706, über 0,6, die Sicherheit ist also hoch.

Der Vorschlag ist M, mit hoher Sicherheit. Je näher ein Nachbar, desto lauter seine Stimme, das ist die ganze Idee.

Die Retourenprüfung in Solidus einbinden

Die Retourenprüfung läuft, nachdem die Bestellung aufgegeben ist, also abonniert sie das Solidus eigene Event order_finalized und erledigt ihre Arbeit in einem Hintergrundjob.9

module Karwendler
  class ReturnRiskSubscriber
    include Omnes::Subscriber

    handle :order_finalized, with: :assess

    def assess(event)
      ReturnRiskJob.perform_later(event.payload[:order].id)
    end
  end
end

module ReturnPrevention
  class Pipeline
    def evaluate(order)
      lines = order.line_items.includes(variant: { option_values: :option_type })
      risks = lines.map do |item|
        risk = ScoringService.return_risk(item, timeout: 2) || { probability: nil }
        { line_item: item, risk: risk, size: check_sizing(item, order.user) }
      end
      { order: order, lines: risks, bracketing: bracketing?(order) }
    end

    private

    def size_of(variant)
      variant.option_values.find { |ov| ov.option_type.name == 'size' }&.name
    end

    def check_sizing(item, user)
      ordered = size_of(item.variant)
      return nil unless ordered && user

      rec = ScoringService.recommend_size(product_id: item.product.id, user_id: user.id)
      return { ordered: ordered, mismatch: false } unless rec && rec[:recommendation]
      return { ordered: ordered, mismatch: false } if rec[:recommendation] == ordered || rec[:confidence] == 'low'

      { ordered: ordered, recommended: rec[:recommendation], mismatch: true }
    end

    def bracketing?(order)
      order.line_items.group_by(&:product).any? { |_, items| items.map(&:variant_id).uniq.size > 1 }
    end
  end
end

Bracketing heißt jetzt: zwei verschiedene Varianten desselben Produkts. Zweimal dieselbe Jacke in derselben Größe, etwa als Geschenk für eine Freundin, zählt nicht mehr.

Die Produkte finden, die immer wieder zurückkommen

Einzelne Bestellungen zu bewerten ist die halbe Arbeit. Die andere Hälfte ist, die Produkte zu reparieren, die Retouren überhaupt erst auslösen. Solidus speichert Retouren als Datensätze vom Typ Spree::ReturnItem, jeweils verknüpft mit einer Inventareinheit und, wo die Kundin einen angegeben hat, einem Spree::ReturnReason.

module ReturnPrevention
  class ProductAnalytics
    def analyse(product)
      returns = Spree::ReturnItem
        .joins(:inventory_unit)
        .where(spree_inventory_units: { variant_id: product.variant_ids })
        .includes(:return_reason, inventory_unit: { variant: { option_values: :option_type } })
        .to_a

      sold = Spree::LineItem
        .joins(:order).merge(Spree::Order.complete)
        .where(variant_id: product.variant_ids)
        .includes(variant: { option_values: :option_type })
        .to_a

      units_sold = sold.sum(&:quantity)
      {
        return_rate: units_sold.positive? ? (returns.size * 100.0 / units_sold).round(1) : 0,
        reasons: returns.group_by { |r| r.return_reason&.name || 'none given' }.transform_values(&:size),
        by_size: by_size(sold, returns)
      }
    end

    private

    def size_of(variant)
      variant.option_values.find { |ov| ov.option_type.name == 'size' }&.name
    end

    def by_size(sold, returns)
      units = sold.group_by { |li| size_of(li.variant) }.transform_values { |lis| lis.sum(&:quantity) }
      back  = returns.group_by { |r| size_of(r.inventory_unit.variant) }.transform_values(&:size)
      units.map do |size, n|
        rate = (back.fetch(size, 0) * 100.0 / n).round(1)
        { size: size, sold: n, returned: back.fetch(size, 0), rate: rate, investigate: rate > 30 }
      end
    end
  end
end

Die erste Fassung hat auf orders und inventory_units als Tabellennamen gefiltert. Solidus stellt seinen Tabellen spree_ voran, diese Abfragen wären also gescheitert. Diese Fassung nutzt Spree::Order.complete, den eigenen Scope des Shops für abgeschlossene Bestellungen, und gruppiert in Ruby. Das liest sich leichter und ist für ein einzelnes Produkt schnell genug.

Beispiel: die Jacke Dachstein nach Größe

Über eine Saison verkauft Karwendler 400 Jacken Dachstein, und 124 kommen zurück, eine Retourenquote von 124/400=31 %124 / 400 = 31\,\%. Nach Größe:

GrößeVerkauftZurückQuote
S602440,0 %
M1505134,0 %
L1303224,6 %
XL601728,3 %

S und M liegen über 30 %, und als Retourengrund steht dreimal so oft "zu klein" wie "zu groß". Die Lösung ist kein Modell. Sie ist eine Zeile auf der Produktseite, "fällt klein aus, nimm eher eine Nummer größer", und eine korrigierte Größentabelle. Das Modell hat es gefunden, ein Mensch behebt es.

Was Vorbeugung wert ist

Die erste Fassung dieses Artikels hatte eine Wirtschaftlichkeitsrechnung mit zwei Rechenfehlern. Sie hat zurückgewonnene Warenkörbe gezählt und dann noch einmal mit einer Conversion Rate von 20 %, und sie hat den Eurowert der Retouren mit Kosten pro Paket multipliziert, als wären Euro Pakete. Hier ist sie noch einmal, sauber gerechnet, an Karwendler.

Beispiel: weniger abgebrochene Warenkörbe

Die Annahmen des Beispiels: 10.000 Warenkörbe im Monat, eine Abbruchquote von 70 %, 3.000 Bestellungen, ein durchschnittlicher Bestellwert von € 70, also € 210.000 Umsatz im Monat, und eine Bruttomarge von 40 %.

Senken die Interventionen auf der Seite die Quote um zwei Punkte, von 70 % auf 68 %:

  • Abgeschlossene Bestellungen: 10.000×(1−0,68)=3.20010{.}000 \times (1 - 0{,}68) = 3{.}200, also 200 mehr als vorher.
  • Zusätzlicher Umsatz: 200×€ 70=€ 14.000200 \times \text{€}\ 70 = \text{€}\ 14{.}000 im Monat.
  • Zusätzliche Marge: € 14.000×0,40=€ 5.600\text{€}\ 14{.}000 \times 0{,}40 = \text{€}\ 5{.}600 im Monat.

Das setzt voraus, dass die zurückgewonnenen Warenkörbe so groß sind wie die durchschnittliche Bestellung. Prüf das, denn die Menschen, die du zurückgewinnst, sind meist die, die dem Kauf am nächsten waren, und sie sind vielleicht nicht typisch.

Beispiel: weniger Retouren

Die Annahmen des Beispiels: Von den 3.000 Bestellungen kommen 25 % zurück, also 750 Retouren im Monat, und jede Retoure kostet Karwendler € 12 für Porto, Bearbeitung und Prüfung.

  • Bearbeitungskosten heute: 750×€ 12=€ 9.000750 \times \text{€}\ 12 = \text{€}\ 9{.}000 im Monat.

Senken Größenvorschläge und bessere Produktseiten die Quote um drei Punkte, von 25 % auf 22 %:

  • Retouren: 3.000×0,22=6603{.}000 \times 0{,}22 = 660, also 90 weniger.
  • Gesparte Bearbeitung: 90×€ 12=€ 1.08090 \times \text{€}\ 12 = \text{€}\ 1{.}080 im Monat.
  • Umsatz, der nicht mehr erstattet wird: 90×€ 70=€ 6.30090 \times \text{€}\ 70 = \text{€}\ 6{.}300, das sind € 6.300×0,40=€ 2.520\text{€}\ 6{.}300 \times 0{,}40 = \text{€}\ 2{.}520 Marge.

Zusammen rund € 3.600 im Monat, und das noch vor der Ware, die beschädigt oder außerhalb der Saison zurückkommt und mit Abschlag verkauft werden muss. Kleinere Zahlen, als die erste Fassung versprochen hat, und echte. Die Modelle müssen nicht perfekt sein. Sie müssen besser sein als nichts zu tun und weniger kosten, als sie sparen.

Wo du anfängst

  1. Miss den Checkout auf dem Server. Zeichne die Zeit pro Schritt auf, Wechsel der Zahlungsart, entfernte Artikel, Versuche mit Rabattcodes und die Zeit auf dem Lieferschritt. Das sind deine Merkmale.
  2. Gib Retouren in Solidus strukturierte Gründe, nie nur Freitext. "Zu klein", "zu groß", "nicht wie abgebildet", "anders entschieden".
  3. Trainiere zuerst das Abbruchmodell. Die Rückmeldung kommt nach Tagen, nicht nach Wochen, und die Interventionen auf der Seite brauchen keine Erlaubnis für Mails.
  4. Ergänze Größenvorschläge, sobald du pro Produktfamilie ein paar Hundert behaltene Bestellungen mit Profildaten hast, und prüf die Abstimmung vor dem Livegang an einer Handvoll echter Bestellungen von Hand.
  5. Schließ den Kreis. Jede Sitzung und jede Bestellung fließt ins nächste Training, und ein monatliches Neutraining hält die Modelle im Takt der Saison.

Wenn dein Checkout leckt und dein Retourenlager voll ist, bring einen Monat Checkout Events und Retourengründe in den kostenlosen Workshop mit. Wir finden heraus, welches der beiden Lecks du zuerst stopfst, und die ersten zwei Wochen Arbeit gehen auf meine Rechnung.

Quellen

  1. 1.

    Baymard Institute, Cart abandonment rate: 50 statistics and why people abandon, aktualisiert am 28. Oktober 2026, https://baymard.com/lists/cart-abandonment-rate

  2. 2.

    National Retail Federation, NRF and Happy Returns report: 2024 retail returns to total $890 billion, Pressemitteilung, 5. Dezember 2024, https://nrf.com/media-center/press-releases/nrf-and-happy-returns-report-2024-retail-returns-total-890-billion

  3. 3.

    scikit learn, GradientBoostingClassifier, API Referenz, abgerufen im November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingClassifier.html

  4. 4.

    scikit learn, precision_recall_curve, API Referenz, abgerufen im November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html

  5. 5.

    Republik Österreich, Telekommunikationsgesetz 2021 (TKG 2021), BGBl. I Nr. 190/2021, § 174 Unerbetene Nachrichten, Absätze 3 und 4, Rechtsinformationssystem des Bundes, 2021, https://www.ris.bka.gv.at/Dokumente/Bundesnormen/NOR40238632/NOR40238632.html

  6. 6.

    Bundesgerichtshof, Urteil vom 10. Juli 2018, VI ZR 225/17, Randnummer 24 (Kundenzufriedenheitsbefragung per Mail als Werbung), BGHZ 219, 233, über dejure.org, https://dejure.org/dienste/vernetzung/rechtsprechung?Gericht=BGH&Datum=10.07.2018&Aktenzeichen=VI%20ZR%20225/17

  7. 7.

    Oberster Gerichtshof, Rechtssatz RS0125490 zu 7 Ob 168/09w vom 30. September 2009 und 4 Ob 13/13k vom 19. März 2013, Begriff "zu Zwecken der Direktwerbung" in der Vorgängerregel § 107 Abs. 2 TKG 2003, Rechtsinformationssystem des Bundes, https://www.ris.bka.gv.at/Dokumente/Justiz/JJR_20090930_OGH0002_0070OB00168_09W0000_001/JJR_20090930_OGH0002_0070OB00168_09W0000_001.html

  8. 8.

    Verwaltungsgerichtshof, Rechtssatz zu 2011/03/0198 vom 19. Dezember 2013, § 107 Abs. 2 TKG 2003 als Umsetzung der Datenschutzrichtlinie für elektronische Kommunikation 2002/58/EG, Rechtsinformationssystem des Bundes, https://www.ris.bka.gv.at/Dokumente/Vwgh/JWR_2011030198_20131219X01/JWR_2011030198_20131219X01.html

  9. 9.

    Solidus Guides, Subscribing to events, abgerufen im November 2026, https://guides.solidus.io/customization/subscribing-to-events

  10. 10.

    scikit learn, Preprocessing data: standardization, or mean removal and variance scaling, Benutzerhandbuch, abgerufen im November 2026, https://scikit-learn.org/stable/modules/preprocessing.html

  11. 11.

    Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 5 Absatz 1 Buchstabe c, Grundsätze für die Verarbeitung personenbezogener Daten (Datenminimierung), EUR-Lex, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679