Recommandations personnalisées
Mis à jour le 29 juillet 2026
Objectifs
- Construire un système de recommandation basé sur les embeddings
- Implémenter la recommandation par contenu et par utilisateur
- Gérer le cold start et la diversité
Le principe : similarité = recommandation
Un moteur de recommandation classique observe les comportements : « les personnes qui ont lu cet article ont aussi lu celui-là ». Il lui faut donc du trafic, beaucoup, avant de dire quoi que ce soit d’utile — c’est le problème du démarrage à froid, qui frappe aussi bien le nouveau catalogue que le nouvel article publié ce matin.
L’approche par embeddings fait l’économie de cette attente. Si un utilisateur a aimé un article, on lui recommande ceux dont les embeddings sont les plus proches. C’est la recommandation content-based : elle se base sur le contenu, pas sur les comportements d’autres utilisateurs. Un article publié il y a dix minutes, qu’aucun lecteur n’a encore ouvert, possède déjà un embedding et devient donc recommandable immédiatement.
Recommandation par contenu
Le catalogue ci-dessous mélange volontairement deux univers — l’apprentissage automatique d’un côté, l’ingénierie logicielle de l’autre — pour que le résultat soit vérifiable. Chaque article est encodé en concaténant son titre et son contenu : le titre porte le sujet en condensé, le contenu apporte le vocabulaire, et l’un sans l’autre dégrade la qualité des voisins trouvés.
La subtilité tient dans la ligne scores[idx] = -1. Sans elle, l’article le plus similaire à un article serait toujours lui-même, avec un score de 1,0 — et vous recommanderiez à un lecteur ce qu’il vient de terminer.
from openai import OpenAI
import numpy as np
client = OpenAI()
# Catalogue d'articles
articles = [
{"id": "a1", "titre": "Introduction au machine learning",
"contenu": "Le ML permet aux machines d'apprendre à partir de données..."},
{"id": "a2", "titre": "Deep learning avec PyTorch",
"contenu": "PyTorch est un framework de deep learning flexible..."},
{"id": "a3", "titre": "Déployer une API REST avec FastAPI",
"contenu": "FastAPI est un framework Python pour créer des APIs..."},
{"id": "a4", "titre": "Les bases de PostgreSQL",
"contenu": "PostgreSQL est un SGBD relationnel open source..."},
{"id": "a5", "titre": "Réseaux de neurones convolutifs",
"contenu": "Les CNN sont utilisés pour la vision par ordinateur..."},
{"id": "a6", "titre": "NLP avec les transformers",
"contenu": "Les transformers ont révolutionné le traitement du langage..."},
]
# Indexer le catalogue
textes = [f"{a['titre']}. {a['contenu']}" for a in articles]
response = client.embeddings.create(
input=textes, model="text-embedding-3-large"
)
catalogue_embs = np.array(
[d.embedding for d in sorted(response.data, key=lambda x: x.index)]
)
def recommander_similaires(
article_id: str,
k: int = 3
) -> list[dict]:
"""Recommande les k articles les plus similaires."""
idx = next(i for i, a in enumerate(articles) if a["id"] == article_id)
article_emb = catalogue_embs[idx]
# Similarité avec tous les autres articles
scores = catalogue_embs @ article_emb
scores[idx] = -1 # Exclure l'article lui-même
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# Un utilisateur lit "Introduction au ML"
recos = recommander_similaires("a1", k=3)
print("Vous avez lu : Introduction au machine learning")
print("Recommandations :")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
Profil utilisateur par historique
Recommander à partir d’un seul article enferme vite le lecteur. Pour tenir compte de tout son parcours, on fabrique un vecteur profil en combinant les embeddings des articles qu’il a consultés : ce point moyen représente ses centres d’intérêt dans le même espace que le catalogue, et l’on cherche ensuite les articles les plus proches de ce point.
Encore faut-il que les lectures d’il y a six mois ne pèsent pas autant que celles d’hier. La pondération par récence s’en charge : le facteur 0.5 ** (n - 1 - i) divise par deux le poids à chaque cran de recul dans l’historique. Un lecteur qui a longtemps suivi le machine learning mais s’est mis récemment aux APIs verra ses recommandations basculer en quelques articles au lieu de rester figées. La normalisation finale ramène le profil sur la sphère unité, condition pour que le produit scalaire avec le catalogue soit bien une similarité cosinus.
def creer_profil_utilisateur(
articles_consultes: list[str],
poids_recency: bool = True
) -> np.ndarray:
"""Crée un vecteur profil à partir de l'historique."""
indices = [
i for i, a in enumerate(articles)
if a["id"] in articles_consultes
]
if not indices:
return np.zeros(catalogue_embs.shape[1])
embs = catalogue_embs[indices]
if poids_recency:
# Les articles récents comptent plus
n = len(indices)
poids = np.array([0.5 ** (n - 1 - i) for i in range(n)])
poids /= poids.sum()
profil = np.average(embs, axis=0, weights=poids)
else:
profil = np.mean(embs, axis=0)
# Normaliser
profil /= np.linalg.norm(profil)
return profil
def recommander_pour_utilisateur(
articles_consultes: list[str],
k: int = 3
) -> list[dict]:
"""Recommande des articles basés sur le profil utilisateur."""
profil = creer_profil_utilisateur(articles_consultes)
scores = catalogue_embs @ profil
# Exclure les articles déjà consultés
for i, a in enumerate(articles):
if a["id"] in articles_consultes:
scores[i] = -1
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# Utilisateur qui a lu du ML et du deep learning
recos = recommander_pour_utilisateur(["a1", "a2"], k=3)
print("Historique : ML + PyTorch")
print("Recommandations :")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
Recommandation avec requête texte
Le profil comportemental ignore une chose que l’utilisateur sait mieux que vous : ce qu’il cherche aujourd’hui. Puisque le catalogue est déjà encodé, il suffit d’encoder une phrase libre pour interroger le même espace. « Je veux apprendre à créer des APIs » remonte l’article FastAPI, quand bien même l’historique du lecteur ne contiendrait que du machine learning. C’est également la seule voie disponible pour un visiteur dont vous ne savez rien.
def recommander_par_interet(
description: str,
k: int = 3
) -> list[dict]:
"""Recommande des articles selon une description d'intérêt."""
query_emb = client.embeddings.create(
input=description,
model="text-embedding-3-large"
).data[0].embedding
scores = catalogue_embs @ np.array(query_emb)
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# "Je veux apprendre à créer des APIs"
recos = recommander_par_interet("créer des APIs web en Python")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
Diversifier les recommandations
Trier par similarité pure produit un défaut visible en production : les cinq recommandations se ressemblent entre elles. Un lecteur de l’article sur les CNN se voit proposer cinq variantes de vision par ordinateur, alors que la deuxième ou la troisième suggestion aurait pu ouvrir sur les transformers.
Le MMR (Maximal Marginal Relevance) corrige cela en sélectionnant les articles un par un : à chaque tour, il retient celui qui maximise la pertinence par rapport à la requête, diminuée de la redondance maximale avec ce qui est déjà retenu. Le lambda_param règle l’arbitrage — à 1,0 vous retrouvez le tri classique, à 0,5 vous privilégiez fortement la variété, et la valeur par défaut de 0,7 donne une liste pertinente mais qui respire.
def recommander_divers(
query_emb: np.ndarray,
k: int = 5,
lambda_param: float = 0.7
) -> list[int]:
"""MMR : équilibre pertinence et diversité."""
scores = catalogue_embs @ query_emb
candidats = list(range(len(articles)))
selectionnes = []
for _ in range(k):
if not candidats:
break
meilleur_score = -float("inf")
meilleur_idx = -1
for idx in candidats:
# Pertinence
pertinence = scores[idx]
# Redondance max avec les déjà sélectionnés
if selectionnes:
sims_selectionnes = [
float(np.dot(catalogue_embs[idx], catalogue_embs[s]))
for s in selectionnes
]
redondance = max(sims_selectionnes)
else:
redondance = 0
# Score MMR
mmr_score = (
lambda_param * pertinence
- (1 - lambda_param) * redondance
)
if mmr_score > meilleur_score:
meilleur_score = mmr_score
meilleur_idx = idx
selectionnes.append(meilleur_idx)
candidats.remove(meilleur_idx)
return selectionnes
Résumé
- Les embeddings permettent de recommander par similarité de contenu
- Le profil utilisateur se construit en moyennant les embeddings consultés
- La pondération par récence donne plus de poids aux consultations récentes
- Le MMR diversifie les recommandations en pénalisant la redondance