Aller au contenu principal

Module 6 — Recommandation profonde et plongements

La factorisation matricielle du module 3 est déjà un modèle à plongements : les vecteurs pup_u et qiq_i sont des plongements appris, la prédiction est un produit scalaire. Le module 6 la généralise en autorisant chaque tour à consommer plus que l'identifiant — des variables démographiques, des plongements de contenu, un historique séquentiel — et à les transformer par un réseau profond. C'est l'architecture qu'utilisent YouTube, Instagram, TikTok, Spotify et LinkedIn en production.

Le modèle à deux tours

L'idée, formalisée par Yi et al. chez Google en 2019, est de séparer strictement la représentation de l'utilisateur et celle du cours. Chaque « tour » est un réseau qui transforme ses entrées en un vecteur dans un espace commun de dimension dd :

  • La tour utilisateur consomme l'identifiant, l'historique, les variables démographiques ; elle sort uRd\mathbf{u} \in \mathbb{R}^d.
  • La tour objet consomme l'identifiant du cours, ses plongements de contenu, sa popularité ; elle sort vRd\mathbf{v} \in \mathbb{R}^d.

La prédiction est simplement s(u,i)=uvs(u, i) = \mathbf{u}^{\top} \mathbf{v}. C'est cette contrainte de découpage qui rend l'architecture utile en production : les vecteurs de cours peuvent être précalculés une fois pour tout le catalogue, indexés dans FAISS ou ScaNN, et la recommandation en direct se réduit à une recherche du plus proche voisin. La tour utilisateur, elle, tourne à la volée sur la requête.

Toute la puissance vient du fait que la tour objet peut consommer le plongement de description vu au module 4 : le cours nouveau reçoit un vecteur v\mathbf{v} dès sa création, sans avoir été suivi par personne. Le collaboratif et le contenu se rejoignent dans une seule architecture, avec un seul entraînement.

import torch
import torch.nn as nn

class TourApprenant(nn.Module):
def __init__(self, n_apprenants, dim_id=32, dim_sortie=64):
super().__init__()
self.plongement = nn.Embedding(n_apprenants, dim_id)
self.reseau = nn.Sequential(
nn.Linear(dim_id, 128), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(128, dim_sortie),
)
def forward(self, u):
return self.reseau(self.plongement(u))

class TourCours(nn.Module):
def __init__(self, n_cours, dim_contenu, dim_id=32, dim_sortie=64):
super().__init__()
self.plongement = nn.Embedding(n_cours, dim_id)
self.reseau = nn.Sequential(
nn.Linear(dim_id + dim_contenu, 128), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(128, dim_sortie),
)
def forward(self, i, contenu_i):
x = torch.cat([self.plongement(i), contenu_i], dim=1)
return self.reseau(x)

Échantillonnage négatif

L'entraînement pose une difficulté propre à la recommandation. Un apprenant a suivi 5 cours parmi 500 ; on connaît donc 5 exemples positifs et 495 exemples potentiellement négatifs. Utiliser tous les négatifs à chaque itération coûte 495 fois plus cher qu'utiliser un positif — inefficace, et biaisé puisque la plupart des « négatifs » sont en réalité des cours que l'apprenant n'a simplement pas vus.

L'échantillonnage négatif consiste à ne comparer chaque positif qu'à un petit nombre KK de cours tirés au hasard (typiquement 5 à 100). La perte devient une entropie croisée sur K+1K+1 candidats :

L=logexp(uv+)exp(uv+)+k=1Kexp(uvk)L = - \log \frac{\exp(\mathbf{u}^{\top} \mathbf{v}^+)}{\exp(\mathbf{u}^{\top} \mathbf{v}^+) + \sum_{k=1}^{K} \exp(\mathbf{u}^{\top} \mathbf{v}^-_k)}

C'est exactement la loss « in-batch softmax » qu'utilise Google : les négatifs d'un exemple (u,i+)(u, i^+) sont les cours positifs des autres apprenants du même lot. On économise ainsi le tirage explicite, on obtient des négatifs gratuits, et la richesse du lot fait que chaque cours « informatif » finit par apparaître.

Deux subtilités valent d'être connues. Un cours populaire est plus souvent tiré comme négatif, ce qui pénalise excessivement sa recommandation ; on corrige par un terme de retrait du log de popularité :

s~(u,i)=uvilogpi\tilde{s}(u, i) = \mathbf{u}^{\top} \mathbf{v}_i - \log p_i

pip_i est la probabilité empirique d'échantillonnage. Ce correcteur, décrit chez Yi et al., stabilise l'entraînement et empêche les cours rares de disparaître. Ensuite, plus KK est grand, plus le gradient est net, mais chaque itération coûte plus cher : le compromis usuel est K=100K = 100 pour l'entraînement, K=500K = 500 pour la validation finale.

def perte_deux_tours(scores_positifs, scores_negatifs):
# scores_positifs : (B,) scores_negatifs : (B, K)
tout = torch.cat([scores_positifs.unsqueeze(1), scores_negatifs], dim=1)
log_softmax = tout - torch.logsumexp(tout, dim=1, keepdim=True)
return -log_softmax[:, 0].mean()

Récupération puis classement

À grande échelle, on n'évalue pas le modèle contre les 500 000 cours du catalogue à chaque requête. On divise le travail en deux étapes.

L'étape de récupération (retrieval) doit produire, en moins de dix millisecondes, quelques centaines de candidats parmi tout le catalogue. C'est là que le modèle à deux tours brille : la tour objet précalcule les vecteurs de tous les cours, on les indexe en HNSW ou en IVF-PQ (FAISS), et la requête devient une recherche approchée par produit scalaire.

L'étape de classement (ranking) prend ces quelques centaines de candidats et applique un modèle plus lourd et plus précis — un réseau qui consomme des variables croisées (utilisateur × cours), les statistiques d'interaction, la fraîcheur, la position d'affichage — pour trier finement. Ce second modèle peut être un DIN, un DIEN, un GBM ou un transformeur, et il peut se permettre d'être coûteux parce qu'il n'a que 500 candidats à noter.

Cette séparation est le squelette architectural de toutes les plateformes de recommandation modernes. La confondre — appliquer une architecture de classement à toute la base — coûte des ordres de grandeur en latence pour un gain marginal, parce que les 499 500 cours écartés par la récupération étaient de toute façon très mauvais.

Recherche de voisins approchés

FAISS (Facebook AI Similarity Search) et ScaNN (Google) implémentent l'état de l'art. Ils reposent sur deux idées combinées : partitionner l'espace en régions et ne chercher que dans les régions les plus prometteuses (IVF, Inverted File Index) ; quantifier les vecteurs en produits de codes courts pour réduire la mémoire (PQ, Product Quantization). Un index IVF-PQ sur un million de cours 128-dimensionnels tient dans 200 Mo et répond en 2 millisecondes par requête, avec un rappel de 95 % par rapport à la recherche exacte.

import faiss
import numpy as np

# V : matrice numpy des plongements des cours, shape (n_cours, d), normée.
d = V.shape[1]
index = faiss.IndexHNSWFlat(d, 32) # 32 voisins par nœud du graphe
index.hnsw.efConstruction = 200
index.add(V.astype("float32"))

# Requête : le vecteur d'un apprenant.
u_vec = tour_apprenant(u).detach().numpy().astype("float32").reshape(1, -1)
scores, indices = index.search(u_vec, k=200) # top 200 candidats

Ce top-200 est ensuite passé au modèle de classement pour produire le top-10 final.

L'échantillonnage négatif ne remplace pas le suivi de popularité

Sans correcteur logpi-\log p_i, un modèle à deux tours entraîné sur MovieLens avec échantillonnage négatif uniforme finit par recommander presque uniquement le top-20 des films populaires — ils apparaissent souvent comme positifs, rarement comme négatifs par accident. La couverture du catalogue chute à 4 %. C'est un bogue silencieux : les métriques rappel@k et NDCG@k s'améliorent, seule la couverture (module 8) le trahit.

En résumé

  • Le modèle à deux tours sépare la représentation utilisateur et objet ; les vecteurs de cours se précalculent et s'indexent.
  • L'échantillonnage négatif rend l'entraînement praticable ; le correcteur logpi-\log p_i empêche les cours populaires d'écraser tout.
  • La chaîne récupération puis classement est la seule architecture qui passe à l'échelle du million de cours en dix millisecondes.
  • FAISS et ScaNN indexent les vecteurs de cours en HNSW ou IVF-PQ ; on paie un peu de rappel contre plusieurs ordres de grandeur en latence.

Module suivant : le cas particulier du démarrage à froid, où aucun tour ne dispose de signal collaboratif.