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 § 7 Abs. 3 UWG, mit Hinweis auf das Widerspruchsrecht bei der Erhebung und in jeder Mail. Auch eine Feedbackbitte per Mail gilt als Werbung.
  • 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 Münchner 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

Bergstoff ist ein erfundener Shop für Outdoorbekleidung in München, unter bergstoff.example, und läuft auf Solidus. Jede Zahl über Bergstoff 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 Bergstoff

Im Beispielmonat legen die Kundinnen und Kunden von Bergstoff 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\,\%.

Bergstoff liegt genau auf dem dokumentierten Durchschnitt. Normal ist trotzdem teuer.

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 Bergstoff etwas?

Die Testdaten von Bergstoff 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 Bergstoff

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 Bergstoff eine hat.
  3. Eine Stammkundin mit acht Bestellungen, die dreimal die Zahlungsart gewechselt hat. Die Engine zeigt Hilfe zur Zahlung. Keinen Rabatt: Sie vertraut Bergstoff 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 Deutschland gilt dafür das Gesetz gegen den unlauteren Wettbewerb (UWG). Nach § 7 Abs. 2 Nr. 2 UWG braucht Werbung per Mail grundsätzlich die vorherige ausdrückliche Einwilligung. Die Ausnahme für Bestandskunden in § 7 Abs. 3 UWG greift nur, wenn du die Adresse im Zusammenhang mit dem Verkauf einer Ware oder Dienstleistung selbst bekommen hast, eigene ähnliche Waren bewirbst, die Person nicht widersprochen hat und du sie bei der Erhebung und in jeder Mail klar darauf hingewiesen hast, dass sie jederzeit widersprechen kann.5 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. Der Hinweis auf das Widerspruchsrecht 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 Deutschland strenger als manche andere Märkte. Der Bundesgerichtshof hat 2018 entschieden, dass auch eine Kundenzufriedenheitsbefragung per Mail Werbung ist, selbst wenn sie zusammen mit der Rechnung kommt.6 Auch eine reine Bitte um Feedback ohne Angebot geht in einem deutschen Shop also 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.7 Abonnenten sollen den Ablauf, der das Event veröffentlicht hat, nicht aufhalten, deshalb stellen sie nur Jobs in die Warteschlange.7

# config/initializers/omnes.rb
Rails.application.config.to_prepare do
  Spree::Bus.register(:bergstoff_cart_abandoned) unless Spree::Bus.registry.registered?(:bergstoff_cart_abandoned)
  Bergstoff::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(:bergstoff_cart_abandoned, order: order, context: context)

# app/subscribers/bergstoff/cart_recovery_subscriber.rb
module Bergstoff
  class CartRecoverySubscriber
    include Omnes::Subscriber

    handle :bergstoff_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 Widerspruch
      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 widersprochen
    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 widersprochen 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 Bergstoff eine Mail bekommen

An einem Dienstag werden 300 Warenkörbe bei Bergstoff 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 und nie widersprochen haben. 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 Bergstoff

Eine Kundin bestellt die Regenjacke Watzmann 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 Bergstoff 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.8 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.9 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.7

module Bergstoff
  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 Watzmann nach Größe

Über eine Saison verkauft Bergstoff 400 Jacken Watzmann, 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 Bergstoff.

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.000 €200 \times 70\ \text{€} = 14{.}000\ \text{€} im Monat.
  • Zusätzliche Marge: 14.000 €×0,40=5.600 €14{.}000\ \text{€} \times 0{,}40 = 5{.}600\ \text{€} 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 Bergstoff 12 € für Porto, Bearbeitung und Prüfung.

  • Bearbeitungskosten heute: 750×12 €=9.000 €750 \times 12\ \text{€} = 9{.}000\ \text{€} 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.080 €90 \times 12\ \text{€} = 1{.}080\ \text{€} im Monat.
  • Umsatz, der nicht mehr erstattet wird: 90×70 €=6.300 €90 \times 70\ \text{€} = 6{.}300\ \text{€}, das sind 6.300 €×0,40=2.520 €6{.}300\ \text{€} \times 0{,}40 = 2{.}520\ \text{€} 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.

Footnotes

  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. 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. scikit learn, GradientBoostingClassifier, API Referenz, abgerufen im November 2026, https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingClassifier.html

  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. Bundesministerium der Justiz, Gesetz gegen den unlauteren Wettbewerb (UWG), § 7 Unzumutbare Belästigungen, Absatz 2 Nummer 2 und Absatz 3, Gesetze im Internet, https://www.gesetze-im-internet.de/uwg_2004/__7.html

  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. Solidus Guides, Subscribing to events, abgerufen im November 2026, https://guides.solidus.io/customization/subscribing-to-events

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

  9. 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

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.