Naar de inhoud
Alle inzichten
Tutorial12 min lezen

Aanbevelingen voor een webwinkel: zet eerst een co-view-baseline live, voeg two-tower-retrieval pas toe als een test zegt dat het loont

Een plan in twee fasen voor productaanbevelingen, met de businesscase, werkende pandas-code voor de baseline, de architectuur die daarna komt, en hoe je beide bewijst in een A/B-test.

Productaanbevelingen zijn een van de weinige ML-systemen die een webwinkel zuiver kan testen: laat de helft van de bezoekers de ene versie zien, de andere helft de andere, en vergelijk de omzet. Dat maakt ze een goed eerste ML-project, en ook een project dat makkelijk te zwaar wordt opgezet. Deze notitie beschrijft de volgorde waarin wij ze bouwen: een co-view-baseline die binnen weken live staat, daarna kandidaatretrieval en ranking als de cijfers dat rechtvaardigen, met code voor de delen die op een pagina passen.

Voor de lezer aan de businesskant

Wat aanbevelingen waard zijn. Salesforce meldde dat AI en agents tijdens de Cyber Week van 2025 ongeveer 20% van de wereldwijde bestellingen beïnvloedden, rond $67 miljard aan omzet, via gepersonaliseerde productaanbevelingen en conversationele klantenservice. Lees dat getal zorgvuldig: "beïnvloed" is attributie op het eigen commerceplatform van Salesforce, geen gemeten uplift, en het mengt aanbevelingen met chat. Het vertelt je dat aanbevelingen bij grote winkels inmiddels standaard zijn. Het vertelt je niet wat ze aan jouw winkel zouden toevoegen. Dat doet alleen een test op jouw verkeer.

Waar ze staan. Elke plaatsing beantwoordt een andere vraag en moet apart worden gemeten.

Plaatsing Vraag die ze beantwoordt Typische bron
Productpagina Wat zou er nog meer passen, als het deze niet wordt? Vergelijkbare en samen bekeken artikelen
Winkelwagen Wat hoort bij wat al gekozen is? Samen gekochte artikelen, accessoires
Home- en categoriepagina's Wat wil deze shopper vandaag waarschijnlijk? Gepersonaliseerde ranking
Zoekresultaten Welke relevante artikelen koopt deze shopper waarschijnlijk? Ranking bovenop zoekrelevantie
E-mail en push Wat brengt deze shopper terug? Dezelfde modellen, in batch gedraaid

Wat je meet. Spreek één primaire metric af voordat er code wordt geschreven: omzet per sessie of bestellingen per sessie over de hele site, niet klikken op de widget. Klikken op aanbevelingen kunnen stijgen terwijl de omzet gelijk blijft, omdat shoppers op een aanbevolen artikel klikken dat ze toch wel hadden gevonden. De click-through van de widget is een nuttige diagnose, geen succescriterium.

Wanneer het niet de moeite waard is. In twee gevallen. Een kleine catalogus, een paar honderd producten die shoppers volledig kunnen doorbladeren, heeft zelden een model nodig; een samengestelde lijst en een sortering op populariteit doen het werk. Weinig verkeer is het tweede geval, en de rekensom is meedogenloos. Om een relatieve lift van 5% op een conversieratio van 2% te detecteren (2,0% naar 2,1%) met de gebruikelijke instellingen (5% significantie, 80% power), heb je ongeveer 315.000 sessies per variant nodig. Een winkel met 100.000 sessies per maand wacht dan meer dan zes maanden op één antwoord. Een lift van 10% vraagt ongeveer 81.000 sessies per variant. Als je verkeer de vraag niet kan beantwoorden, kan niet worden bewezen dat een model helpt, en betaal je voor een overtuiging.

Kosten en doorlooptijd. Onze bandbreedte voor een aanbevelingssysteem is €14k–42k over 2–4 maanden, afhankelijk van hoe ver het project voorbij de baseline gaat. Een illustratieve verdeling staat aan het eind van deze notitie.

Fase 1: co-views met popularity als terugval

De baseline is item-to-item: producten die in dezelfde sessies voorkomen, hangen waarschijnlijk samen. Het idee is oud, en een paper in IEEE Internet Computing uit 2003 van engineers van Amazon beschreef de item-to-item-aanpak op schaal: bereken vergelijkbare artikelen offline, zodat de kosten van het online opzoeken niet meegroeien met het aantal klanten of producten. Het is nog steeds de juiste eerste stap, omdat het niets nodig heeft behalve het eventlog, makkelijk uit te leggen is aan een merchandiser, en elk later model een getal geeft om te verslaan.

Input: één rij per productweergave (of toevoeging aan de winkelwagen, of aankoop) met session_id, item_id en ts.

import numpy as np
import pandas as pd

def co_view_scores(events, max_items_per_session=50, min_pairs=3):
    # one row per session and item; long sessions are capped to keep the join small
    s = (events.sort_values("ts")
               .drop_duplicates(["session_id", "item_id"], keep="last")
               .groupby("session_id").tail(max_items_per_session)[["session_id", "item_id"]])
    pairs = s.merge(s, on="session_id", suffixes=("_a", "_b"))
    pairs = pairs[pairs.item_id_a != pairs.item_id_b]
    co = pairs.groupby(["item_id_a", "item_id_b"]).size().rename("co").reset_index()
    co = co[co.co >= min_pairs]                     # drop pairs seen in too few sessions
    n_item = s.groupby("item_id").size()            # sessions that viewed each item
    co["score"] = co.co / np.sqrt(co.item_id_a.map(n_item) * co.item_id_b.map(n_item))
    return co

def top_k(co, k=20):
    return (co.sort_values(["item_id_a", "score"], ascending=[True, False])
              .groupby("item_id_a").head(k))

Drie keuzes in deze code zijn belangrijker dan het model ooit zal zijn.

  • Normalisatie. Ruwe co-occurrence-tellingen zetten bestsellers naast alles. Delen door de wortel van het aantal sessies van beide artikelen (een cosinusscore) beloont paren die vaker samen voorkomen dan hun populariteit voorspelt. Een liftscore, co * n_sessions / (n_a * n_b), duwt verder richting nicheparen en vraagt een strengere minimumtelling.
  • Minimale support. Zonder min_pairs krijgen twee artikelen die één keer door één persoon samen zijn bekeken een perfecte score. Stel de drempel af op achtergehouden data, niet op het oog.
  • Venster en signaal. Gebruik een rollend venster (60–90 dagen is een gangbaar begin), zodat seizoensartikelen uitdoven. Co-views vinden alternatieven voor de productpagina; co-purchases vinden aanvullende producten voor de winkelwagen. Bouw beide met dezelfde functie op verschillende eventtypes.

Vul voor artikelen met te weinig buren de plekken aan met populaire artikelen uit dezelfde categorie:

def recommend(item_id, recs, catalog, popular_by_cat, k=10):
    own = recs.loc[recs.item_id_a == item_id, "item_id_b"].tolist()[:k]
    if len(own) < k:                                # fallback: popular in the same category
        cat = catalog.set_index("item_id").at[item_id, "category"]
        own += [i for i in popular_by_cat[cat] if i != item_id and i not in own][: k - len(own)]
    return own

In productie wordt dit een nachtelijke batchjob die de top 20 buren per artikel naar een key-value store schrijft, en een API die ze uitleest. Dat is genoeg om de eerste A/B-test te draaien tegen wat de winkel vandaag laat zien.

Fase 2: kandidaten ophalen, daarna rangschikken

Item-to-item negeert wie de shopper is. Personalisatie vraagt een model dat artikelen voor een persoon scoort, en een hele catalogus scoren bij elke paginaweergave is te traag. Het standaardantwoord is twee fasen, beschreven in het paper over YouTube-aanbevelingen van Covington, Adams en Sargin (RecSys 2016): een fase voor kandidaatgeneratie brengt een corpus van miljoenen terug tot honderden artikelen, en een apart rankingmodel zet die honderden op volgorde met een veel rijkere set features.

Kandidaatgeneratie met een two-tower-model. Het ene netwerk maakt een embedding van de shopper (recente weergaven, winkelwagen, context zoals apparaat en instappagina), het andere van het artikel (categorie, merk, prijs, tekst). De training trekt de embedding van een shopper dicht naar de artikelen waarmee die interactie had. Bij het serveren zijn de artikelembeddings vooraf berekend en geïndexeerd voor approximate nearest-neighbour search, dus retrieval is één embeddingberekening en één indexopzoeking.

De gebruikelijke trainingstruc is in-batch negatives: voor elke shopper in een batch dienen de artikelen van de andere shoppers als negatieven. Die truc heeft een bias. Populaire artikelen komen in veel batches voor, dus ze worden te vaak als negatief gebruikt en het model leert ze te laag te rangschikken. Yi et al. (RecSys 2019) lieten zien hoe je daarvoor corrigeert door de log van de geschatte samplingfrequentie van elk artikel van de score af te trekken, en zetten het gecorrigeerde model in bij YouTube over een corpus van tientallen miljoenen video's. In PyTorch is de kern kort:

import torch
import torch.nn as nn
import torch.nn.functional as F

class Tower(nn.Module):
    def __init__(self, n_ids, dim=64):
        super().__init__()
        self.emb = nn.Embedding(n_ids, dim)
        self.mlp = nn.Sequential(nn.Linear(dim, dim), nn.ReLU(), nn.Linear(dim, dim))

    def forward(self, ids):
        return F.normalize(self.mlp(self.emb(ids)), dim=-1)

user_tower, item_tower = Tower(n_users), Tower(n_items)

def in_batch_loss(user_ids, item_ids, log_q, temperature=0.05):
    u = user_tower(user_ids)                 # [B, d]
    v = item_tower(item_ids)                 # [B, d]
    logits = u @ v.T / temperature           # other items in the batch act as negatives
    logits = logits - log_q[item_ids]        # logQ correction for popular items
    labels = torch.arange(len(user_ids))     # the diagonal holds the true pairs
    return F.cross_entropy(logits, labels)

Hier is log_q de log van het aandeel van elk artikel in de trainingsinteracties. De towers nemen ID's om het voorbeeld klein te houden; in een echte winkel neemt de user tower de laatste N bekeken artikelen en context in plaats van een gebruikers-ID, wat ook betekent dat een bezoeker die voor het eerst komt na één klik al een embedding krijgt.

Houd de co-view-lijsten uit fase 1 aan als tweede kandidaatbron. Two-tower-retrieval en co-views falen op verschillende manieren, en de ranker kan beide gebruiken.

Ranking. De ranker ziet per request een paar honderd kandidaten en kan zich veel features veroorloven: de scores van elke kandidaatbron, de prijs ten opzichte van de gebruikelijke prijsklasse van de shopper, of het artikel een categorie deelt met recente weergaven, de recente trend in weergaven van het artikel, het aantal dagen sinds het artikel is toegevoegd. Een gradient-boosted learning-to-rank-model is een gangbare eerste ranker, en de feature importances zijn makkelijk te inspecteren:

import lightgbm as lgb

# one row per (request, candidate); label 1 if the candidate was clicked or bought
features = ["covis_score", "two_tower_score", "price_vs_user_median",
            "same_category_as_viewed", "item_views_7d_trend", "days_since_added"]
train = train.sort_values("request_id")
ranker = lgb.LGBMRanker(objective="lambdarank", n_estimators=300, learning_rate=0.05)
ranker.fit(train[features], train["label"],
           group=train.groupby("request_id", sort=False).size().to_numpy())

Businessregels na het model. Voorraad, marge en beleid horen na de ranking, in gewone code die de business kan lezen en wijzigen: haal artikelen weg die niet geleverd kunnen worden, beperk het aantal artikelen per merk in één blok, sluit categorieën uit die niet mogen worden gecrosssold, en als marge telt, pas dan een begrensde boost toe in plaats van het model opnieuw op marge te trainen. Door regels buiten het model te houden, kan een merchandiser ze op een dinsdag aanpassen zonder ML-release.

Offline evaluatie, en waarom die niet het antwoord is

Splits op tijd, niet willekeurig: train op alles vóór een peildatum, evalueer op de week erna. Een willekeurige split lekt de toekomst in de training en blaast elke metric op.

Meet elke fase met een eigen metric. Voor kandidaatgeneratie recall@k: van de artikelen waar de shopper daarna echt iets mee deed, hoeveel stonden er in de kandidatenlijst. Voor ranking NDCG@k: kwamen die artikelen bovenaan terecht.

def recall_at_k(recommended, relevant, k=20):
    return len(set(recommended[:k]) & relevant) / len(relevant)

def ndcg_at_k(recommended, relevant, k=10):
    dcg = sum(1 / np.log2(i + 2) for i, item in enumerate(recommended[:k]) if item in relevant)
    ideal = sum(1 / np.log2(i + 2) for i in range(min(len(relevant), k)))
    return dcg / ideal

Offline cijfers bepalen welk model de test in gaat. Ze bepalen niet of het live gaat. De logs zijn door het oude systeem geproduceerd, dus het nieuwe model wordt beoordeeld op wat shoppers deden toen ze iets anders te zien kregen, en de positie op de pagina stuurt klikken ongeacht de relevantie. Covington et al. schreven dat "live A/B results are not always correlated with offline experiments", en de twee redenen hierboven zijn genoeg om dat te verwachten.

De A/B-test

  • Eén primaire metric, de metric die aan het begin is afgesproken, zoals omzet per sessie. Bepaal vóór de lancering de kleinste lift die het detecteren waard is en bereken de steekproefomvang, zoals in de rekensom hierboven.
  • Randomiseer per bezoeker, niet per paginaweergave, zodat één shopper niet beide versies ziet.
  • Draai hele weken, minstens twee, omdat shoppers doordeweeks en in het weekend anders gedrag vertonen. Stop niet vroeg omdat de curve er op dag vier goed uitziet.
  • Vangrailmetrics die niet slechter mogen worden: responstijd van de pagina, het aandeel aanbevolen artikelen dat bij een klik niet op voorraad is, het retourpercentage van bestellingen met aanbevolen artikelen, en de omzet in categorieën die het nieuwe model minder vaak toont.
  • Houd het oude systeem aan als controle totdat de primaire metric iets anders zegt, en houd de schakelaar om terug te draaien.

Cold start

Nieuwe producten hebben geen interacties, dus noch co-views noch de ID-embedding van de item tower kent ze. Plaats ze op basis van attributen (categorie, merk, prijsklasse, tekst- en beeldembeddings) en reserveer in elk blok een klein aantal plekken voor recent toegevoegde artikelen, zodat ze de interacties verzamelen die ze nodig hebben. Nieuwe bezoekers krijgen kandidaten op basis van context (de pagina waarop ze binnenkwamen, de categorie, het apparaat) en populaire artikelen, en gaan na hun eerste paar weergaven in de sessie over op gepersonaliseerde retrieval.

Wat het kost, fase voor fase

Onze bandbreedte voor een aanbevelingssysteem is €14k–42k over 2–4 maanden. De tabel hieronder is een illustratieve verdeling voor een middelgroot project dat beide fasen doorloopt, geprijsd volgens onze gepubliceerde tarieven. Het is geen offerte en geen verslag van een eerder project.

Fase Rollen en uren Kosten
Data-audit, metric, opzet A/B-test Data 30 u à €45, Senior ML 20 u à €70 €2.750
Fase 1: co-views, terugval, API ML 60 u à €50, Backend 40 u à €40 €4.600
Fase 2: two-tower-retrieval en ranker Senior ML 80 u à €70, ML 80 u à €50 €9.600
A/B-test draaien en uitlezen Senior ML 15 u à €70, Data 20 u à €45 €1.950
Pipelines, monitoring, rollback MLOps 80 u à €55 €4.400
Over alle fasen QA 30 u à €30, PM 50 u à €40 €2.900
Totaal 505 u €26.200

Een project dat na fase 1 stopt, omdat de eerste test de businessvraag beantwoordde, zit aan de onderkant van de bandbreedte. Catalogi met veel databronnen, meerdere plaatsingen die apart worden getest, of strikte budgetten voor responstijd duwen richting de bovenkant. De pagina over aanbevelingssystemen beschrijft hoe zo'n project verloopt, en de calculator op /pricing prijst je eigen scope met dezelfde tarieven.

Bronnen

Een proces dat te veel kost?

Vertel ons erover. Het eerste gesprek is gratis, en als AI niet het antwoord is, zeggen we dat.

Bespreek je vraagstuk