Saltar para o conteúdo
Todos os artigos
Tutorial13 min de leitura

Recomendações para uma loja online: lance primeiro uma baseline de co-visualizações e acrescente recuperação two-tower só quando um teste mostrar que compensa

Um plano em duas fases para recomendações de produtos, com o caso de negócio, código pandas funcional para a baseline, a arquitetura que vem a seguir e como provar cada uma num teste A/B.

As recomendações de produtos são um dos poucos sistemas de ML que uma loja online consegue testar de forma limpa: mostra-se uma versão a metade dos visitantes, a outra à outra metade, e compara-se a receita. Isso torna-as um bom primeiro projeto de ML, e também um projeto fácil de sobredimensionar. Esta nota descreve a ordem pela qual as construímos: uma baseline de co-visualizações que fica pronta em semanas, depois recuperação de candidatos e ranking se os números o justificarem, com código para as partes que cabem numa página.

Para o leitor do lado do negócio

Quanto valem as recomendações. A Salesforce reportou que, durante a Cyber Week de 2025, a IA e os agentes influenciaram cerca de 20% das encomendas a nível mundial, cerca de 67 mil milhões de dólares em vendas, através de recomendações personalizadas de produtos e de atendimento conversacional. Leia esse número com cuidado: «influenciaram» é atribuição na própria plataforma de comércio da Salesforce, não um aumento medido, e mistura recomendações com chat. Diz-lhe que as recomendações são hoje padrão nas grandes lojas. Não lhe diz o que acrescentariam à sua. Só um teste com o seu tráfego o diz.

Onde ficam. Cada posição responde a uma pergunta diferente e deve ser medida separadamente.

Posição Pergunta a que responde Fonte típica
Página de produto Que mais poderia servir, se não este? Itens semelhantes e co-visualizados
Carrinho O que combina com o que já foi escolhido? Itens comprados em conjunto, acessórios
Página inicial e páginas de categoria O que é provável que este cliente queira hoje? Ranking personalizado
Resultados de pesquisa Que itens relevantes é provável que este cliente compre? Ranking por cima da relevância da pesquisa
E-mail e push O que traz este cliente de volta? Os mesmos modelos, em batch

O que medir. Acorde uma métrica principal antes de escrever qualquer código: receita por sessão ou encomendas por sessão em todo o site, não cliques no widget. Os cliques nas recomendações podem subir enquanto a receita fica estável, porque os clientes clicam num item recomendado que teriam encontrado de qualquer forma. A taxa de cliques do widget é um diagnóstico útil, não um critério de sucesso.

Quando não vale a pena. Dois casos. Um catálogo pequeno, algumas centenas de produtos que os clientes conseguem percorrer por inteiro, raramente precisa de um modelo; uma lista curada e uma ordenação por popularidade fazem o trabalho. O segundo caso é pouco tráfego, e a aritmética não perdoa. Para detetar um aumento relativo de 5% numa taxa de conversão de 2% (de 2,0% para 2,1%) com as definições habituais (5% de significância, 80% de potência), precisa de cerca de 315.000 sessões em cada braço. Uma loja com 100.000 sessões por mês esperaria mais de seis meses por uma resposta. Um aumento de 10% precisa de cerca de 81.000 sessões por braço. Se o seu tráfego não consegue responder à pergunta, não é possível provar que um modelo ajuda, e está a pagar por uma crença.

Custo e prazo. O nosso intervalo para um sistema de recomendação é de 14k–42k € em 2–4 meses, conforme o quanto o projeto avança para lá da baseline. Há uma divisão ilustrativa no fim desta nota.

Fase 1: co-visualizações com alternativa por popularidade

A baseline é item-a-item: produtos que aparecem nas mesmas sessões provavelmente estão relacionados. A ideia é antiga, e um artigo de 2003 na IEEE Internet Computing, escrito por engenheiros da Amazon, descreveu a abordagem item-a-item em escala: calcular os itens semelhantes offline, de modo a que o custo da consulta online não cresça com o número de clientes ou de produtos. Continua a ser o primeiro passo certo, porque não precisa de nada além do registo de eventos, é fácil de explicar a um merchandiser e dá a qualquer modelo posterior um número a superar.

Entrada: uma linha por visualização de produto (ou adição ao carrinho, ou compra) com session_id, item_id e 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))

Há três escolhas neste código que importam mais do que alguma vez importará o modelo.

  • Normalização. As contagens brutas de co-ocorrência recomendam os mais vendidos ao lado de tudo. Dividir pela raiz quadrada das contagens de sessões dos dois itens (uma pontuação de cosseno) recompensa pares que aparecem juntos mais vezes do que a sua popularidade prevê. Uma pontuação de lift, co * n_sessions / (n_a * n_b), puxa ainda mais para pares de nicho e precisa de uma contagem mínima mais exigente.
  • Suporte mínimo. Sem min_pairs, dois itens vistos juntos uma vez por uma pessoa obtêm uma pontuação perfeita. Ajuste o limiar em dados reservados, não a olho.
  • Janela e sinal. Use uma janela deslizante (60–90 dias é um início comum) para que os itens sazonais desapareçam. As co-visualizações encontram alternativas para a página de produto; as co-compras encontram complementos para o carrinho. Construa ambas a partir da mesma função com tipos de evento diferentes.

Para itens com poucos vizinhos, preencha as posições com itens populares da mesma categoria:

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

Em produção, isto passa a ser um job batch noturno que escreve os 20 vizinhos principais de cada item num armazenamento chave-valor, e uma API que os lê. Chega para correr o primeiro teste A/B contra o que quer que a loja mostre hoje.

Fase 2: recuperar candidatos e depois ordená-los

O item-a-item ignora quem é o cliente. A personalização precisa de um modelo que pontue itens para uma pessoa, e pontuar um catálogo inteiro a cada visualização de página é demasiado lento. A resposta padrão são duas fases, descritas no artigo sobre as recomendações do YouTube de Covington, Adams e Sargin (RecSys 2016): uma fase de geração de candidatos reduz um corpus de milhões a centenas de itens, e um modelo de ranking separado ordena essas centenas com um conjunto de features muito mais rico.

Geração de candidatos com um modelo two-tower. Uma rede gera o embedding do cliente (visualizações recentes, carrinho, contexto como dispositivo e página de entrada), outra gera o do item (categoria, marca, preço, texto). O treino aproxima o embedding de um cliente dos itens com que ele interagiu. Na fase de serviço, os embeddings dos itens são pré-calculados e indexados para pesquisa aproximada de vizinhos mais próximos, por isso a recuperação é um cálculo de embedding e uma consulta ao índice.

O truque de treino habitual são os negativos dentro do batch: para cada cliente num batch, os itens dos outros clientes servem de negativos. Esse truque tem um enviesamento. Os itens populares aparecem em muitos batches, por isso são usados em excesso como negativos e o modelo aprende a subordená-los. Yi et al. (RecSys 2019) mostraram como corrigir isto subtraindo à pontuação de cada item o logaritmo da sua frequência de amostragem estimada, e implementaram o modelo corrigido no YouTube sobre um corpus de dezenas de milhões de vídeos. Em PyTorch, o núcleo é curto:

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)

Aqui, log_q é o logaritmo da fração de interações de treino de cada item. As torres recebem IDs para manter o exemplo pequeno; numa loja real, a torre do utilizador recebe os últimos N itens vistos e o contexto em vez de um ID de utilizador, o que também significa que um visitante novo já tem um embedding após um clique.

Mantenha as listas de co-visualizações da fase 1 como segunda fonte de candidatos. A recuperação two-tower e as co-visualizações falham de formas diferentes, e o ranker pode usar ambas.

Ranking. O ranker vê algumas centenas de candidatos por pedido e pode permitir-se muitas features: as pontuações de cada fonte de candidatos, o preço face à faixa de preço habitual do cliente, se o item partilha categoria com as visualizações recentes, a tendência recente de visualizações do item, os dias desde que o item foi adicionado. Um modelo de learning-to-rank com gradient boosting é um primeiro ranker comum, e as importâncias das suas features são fáceis de inspecionar:

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())

Regras de negócio depois do modelo. Stock, margem e política vêm depois do ranking, em código simples que o negócio consegue ler e alterar: retirar itens que não podem ser enviados, limitar o número de itens por marca num bloco, excluir categorias que não podem ser vendidas em cruzado e, se a margem importar, aplicar um reforço limitado em vez de retreinar o modelo com a margem. Manter as regras fora do modelo significa que um merchandiser as pode alterar numa terça-feira sem um lançamento de ML.

Avaliação offline, e porque não é a resposta

Divida por tempo, não aleatoriamente: treine com tudo o que é anterior a uma data de corte e avalie na semana seguinte. Uma divisão aleatória deixa o futuro entrar no treino e inflaciona todas as métricas.

Meça cada fase com a sua própria métrica. Para a geração de candidatos, recall@k: dos itens com que o cliente interagiu a seguir, quantos estavam na lista de candidatos. Para o ranking, NDCG@k: esses itens ficaram perto do topo?

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

Os números offline decidem que modelo entra no teste. Não decidem se ele vai para produção. Os registos foram produzidos pelo sistema antigo, por isso o novo modelo é avaliado pelo que os clientes fizeram quando lhes foi mostrada outra coisa, e a posição na página condiciona os cliques independentemente da relevância. Covington et al. escreveram que «live A/B results are not always correlated with offline experiments», e as duas razões acima chegam para o esperar.

O teste A/B

  • Uma métrica principal, a acordada no início, como a receita por sessão. Decida o aumento mínimo que vale a pena detetar e calcule o tamanho da amostra antes do lançamento, como na aritmética acima.
  • Aleatorize por visitante, não por visualização de página, para que um cliente não veja as duas versões.
  • Corra semanas completas, pelo menos duas, porque os clientes de dias úteis e de fim de semana se comportam de forma diferente. Não pare mais cedo porque a curva parece boa ao quarto dia.
  • Métricas de salvaguarda que não podem piorar: tempo de resposta da página, a fração de itens recomendados que estão esgotados quando são clicados, taxa de devolução de encomendas com itens recomendados e receita nas categorias que o novo modelo mostra com menos frequência.
  • Mantenha o sistema antigo como controlo até a métrica principal dizer o contrário, e mantenha o interruptor para reverter.

Arranque a frio

Os produtos novos não têm interações, por isso nem as co-visualizações nem o embedding de ID da torre de itens os conhecem. Posicione-os através de atributos (categoria, marca, faixa de preço, embeddings de texto e de imagem) e reserve um pequeno número de posições em cada bloco para itens adicionados recentemente, para que recolham as interações de que precisam. Os visitantes novos recebem candidatos baseados no contexto (a página onde chegaram, a categoria, o dispositivo) e itens populares, e passam para a recuperação personalizada após as primeiras visualizações na sessão.

Quanto custa, fase a fase

O nosso intervalo para um sistema de recomendação é de 14k–42k € em 2–4 meses. A tabela abaixo é uma divisão ilustrativa para um projeto de dimensão média que passa pelas duas fases, orçamentado a partir da nossa tabela de tarifas publicada. Não é um orçamento nem o registo de um projeto passado.

Fase Funções e horas Custo
Auditoria de dados, métrica, desenho do teste A/B Data 30 h a 45 €, Senior ML 20 h a 70 € 2.750 €
Fase 1: co-visualizações, alternativa, API ML 60 h a 50 €, Backend 40 h a 40 € 4.600 €
Fase 2: recuperação two-tower e ranker Senior ML 80 h a 70 €, ML 80 h a 50 € 9.600 €
Execução do teste A/B e leitura dos resultados Senior ML 15 h a 70 €, Data 20 h a 45 € 1.950 €
Pipelines, monitorização, rollback MLOps 80 h a 55 € 4.400 €
Transversal a todas as fases QA 30 h a 30 €, PM 50 h a 40 € 2.900 €
Total 505 h 26.200 €

Um projeto que para depois da fase 1, porque o primeiro teste respondeu à pergunta de negócio, fica no extremo inferior do intervalo. Catálogos com muitas fontes de dados, várias posições testadas separadamente ou orçamentos de tempo de resposta apertados empurram para o extremo superior. A página de sistemas de recomendação descreve como decorre um projeto destes, e a calculadora em /pricing orça o seu próprio âmbito a partir da mesma tabela de tarifas.

Fontes

Tem um processo que custa demasiado?

Fale-nos dele. A primeira chamada é gratuita e, se a IA não for a resposta, dizemo-lo.

Falar sobre a sua tarefa