De Netflix à Spotify, comprendre et implémenter les algorithmes qui personnalisent l'expérience de milliards d'utilisateurs. Cours complets + exercices pratiques.
Un chemin structuré du débutant à l'expert en systèmes de recommandation
Cliquez sur un module pour accéder aux cours et exercices
Un système de recommandation est un filtre d'information qui prédit la "note" ou la "préférence" qu'un utilisateur donnerait à un item. Objectif : présenter les items les plus pertinents à chaque utilisateur.
Exploite le comportement collectif des utilisateurs. "Des utilisateurs similaires à toi ont aimé ces items."
Analyse les attributs des items. "Tu as aimé ce film d'action avec X, voici d'autres films similaires."
Combine plusieurs approches pour tirer parti de leurs forces et compenser leurs faiblesses.
Le cœur de tout système de recommandation est la matrice R de taille (m × n) où m = utilisateurs, n = items. Les cellules contiennent les notes (explicites) ou les interactions (implicites).
| User \ Film | 🦁 Lion | 🚀 Interstellar | 😂 Intouchables | 👻 Get Out | 🕷️ Spider-Man |
|---|---|---|---|---|---|
| Alice | 5 | 4 | ? | 2 | ? |
| Bob | ? | 5 | 1 | ? | 4 |
| Carol | 3 | ? | 5 | 4 | ? |
| Dave | ? | 3 | ? | 5 | 3 |
💡 Les ? sont les valeurs à prédire. La matrice est typiquement très sparse (95-99% de valeurs manquantes).
Mesure l'erreur de prédiction des notes. Pénalise fortement les grandes erreurs.
Erreur absolue moyenne. Plus robuste aux outliers que le RMSE.
Évalue la qualité des Top-K recommandations présentées à l'utilisateur.
Normalized Discounted Cumulative Gain. Tient compte de l'ordre des recommandations.
import numpy as np import pandas as pd from sklearn.metrics import mean_squared_error, mean_absolute_error # ─── Création d'une matrice utilisateur-item sparse ─────────────── data = { 'user': ['Alice', 'Alice', 'Bob', 'Bob', 'Carol', 'Carol'], 'item': ['Lion', 'Inter', 'Inter', 'Spider','Lion', 'Intou'], 'rating': [5, 4, 5, 4, 3, 5 ] } df = pd.DataFrame(data) # ─── Pivot : matrice R (users × items) ─────────────────────────── R = df.pivot(index='user', columns='item', values='rating') print("Matrice Utilisateur-Item :") print(R) print(f"\nSparsité : {R.isna().sum().sum() / R.size * 100:.1f}%") # ─── Calcul des métriques ───────────────────────────────────────── def compute_metrics(y_true, y_pred): """Calcule RMSE et MAE.""" rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mae = mean_absolute_error(y_true, y_pred) return {'RMSE': round(rmse, 4), 'MAE': round(mae, 4)} # Exemple y_true = [5, 4, 3, 2, 5] y_pred = [4.5, 3.8, 3.2, 2.5, 4.7] metrics = compute_metrics(y_true, y_pred) print(f"\nRMSE : {metrics['RMSE']} | MAE : {metrics['MAE']}") # ─── Precision@K et Recall@K ───────────────────────────────────── def precision_recall_at_k(recommended, relevant, k): """ recommended : liste ordonnée des items recommandés relevant : set des items réellement pertinents k : nombre de recommandations à évaluer """ top_k = recommended[:k] hits = len(set(top_k) & set(relevant)) precision = hits / k recall = hits / len(relevant) if relevant else 0 return precision, recall recommended = ['Spider-Man', 'Interstellar', 'Get Out', 'Lion'] relevant = {'Interstellar', 'Get Out'} p, r = precision_recall_at_k(recommended, relevant, k=3) print(f"\nPrecision@3 : {p:.2f} | Recall@3 : {r:.2f}")
Vous disposez du dataset suivant de notes de films (1 à 5 étoiles). Créez la matrice utilisateur-item, calculez la sparsité et affichez les statistiques descriptives.
ratings_data = [
("User1", "Inception", 5),
("User1", "The Matrix", 4),
("User1", "Titanic", 2),
("User2", "Inception", 4),
("User2", "Avengers", 5),
("User2", "Titanic", 3),
("User3", "The Matrix", 5),
("User3", "Avengers", 4),
("User3", "Interstellar", 5),
("User4", "Titanic", 5),
("User4", "Interstellar", 3),
("User5", "Inception", 3),
("User5", "Avengers", 4),
("User5", "Interstellar", 4),
]
# TODO :
# 1. Créer un DataFrame pandas
# 2. Générer la matrice pivot (users × items)
# 3. Calculer et afficher la sparsité
# 4. Afficher : note moyenne par user, note moyenne par film
# 5. Trouver l'utilisateur le plus actif et le film le plus noté
Utilisez pd.pivot_table() avec aggfunc='mean'. La sparsité = proportion de NaN dans la matrice.
import pandas as pd import numpy as np ratings_data = [ ("User1", "Inception", 5), ("User1", "The Matrix", 4), ("User1", "Titanic", 2), ("User2", "Inception", 4), ("User2", "Avengers", 5), ("User2", "Titanic", 3), ("User3", "The Matrix", 5), ("User3", "Avengers", 4), ("User3", "Interstellar", 5), ("User4", "Titanic", 5), ("User4", "Interstellar", 3), ("User5", "Inception", 3), ("User5", "Avengers", 4), ("User5", "Interstellar", 4), ] # 1. DataFrame df = pd.DataFrame(ratings_data, columns=['user', 'item', 'rating']) # 2. Matrice pivot R = df.pivot_table(index='user', columns='item', values='rating') # 3. Sparsité n_total = R.size n_missing = R.isna().sum().sum() sparsity = n_missing / n_total * 100 print(f"📊 Matrice {R.shape[0]} users × {R.shape[1]} films") print(f"🕳️ Sparsité : {sparsity:.1f}% ({n_missing}/{n_total} valeurs manquantes)\n") print(R.to_string()) # 4. Statistiques print("\n📈 Note moyenne par utilisateur :") print(df.groupby('user')['rating'].mean().round(2)) print("\n🎬 Note moyenne par film :") print(df.groupby('item')['rating'].mean().round(2)) # 5. Records most_active = df['user'].value_counts().idxmax() most_rated = df['item'].value_counts().idxmax() print(f"\n🏆 Utilisateur le plus actif : {most_active}") print(f"🎬 Film le plus noté : {most_rated}")
Implémentez from scratch (sans sklearn) les fonctions RMSE, MAE, Precision@K, Recall@K et NDCG@K. Validez vos résultats en les comparant à sklearn.
DCG@K = Σ(rᵢ / log₂(i+1)) pour i=1..K. NDCG@K = DCG@K / IDCG@K où IDCG est le DCG idéal (items triés par pertinence décroissante).
import numpy as np # ─── RMSE & MAE from scratch ───────────────────────────────────── def rmse(y_true, y_pred): y_true, y_pred = np.array(y_true), np.array(y_pred) return np.sqrt(np.mean((y_true - y_pred) ** 2)) def mae(y_true, y_pred): y_true, y_pred = np.array(y_true), np.array(y_pred) return np.mean(np.abs(y_true - y_pred)) # ─── Precision@K & Recall@K ────────────────────────────────────── def precision_at_k(recommended, relevant, k): top_k = recommended[:k] hits = len(set(top_k) & set(relevant)) return hits / k def recall_at_k(recommended, relevant, k): top_k = recommended[:k] hits = len(set(top_k) & set(relevant)) return hits / len(relevant) if relevant else 0 # ─── NDCG@K ────────────────────────────────────────────────────── def dcg_at_k(scores, k): scores = np.array(scores[:k], dtype=float) gains = scores / np.log2(np.arange(2, len(scores) + 2)) return np.sum(gains) def ndcg_at_k(recommended_scores, k): """ recommended_scores : pertinence des items dans l'ordre recommandé ex: [1, 0, 1, 0, 1] -> 1=pertinent, 0=non pertinent """ dcg = dcg_at_k(recommended_scores, k) idcg = dcg_at_k(sorted(recommended_scores, reverse=True), k) return dcg / idcg if idcg > 0 else 0 # ─── Tests ─────────────────────────────────────────────────────── y_true = [5, 3, 4, 2, 5] y_pred = [4.5, 3.2, 4.1, 1.8, 4.9] print(f"RMSE : {rmse(y_true, y_pred):.4f}") print(f"MAE : {mae(y_true, y_pred):.4f}") recommended = ['A', 'B', 'C', 'D', 'E'] relevant = {'A', 'C', 'E'} print(f"\nPrecision@3 : {precision_at_k(recommended, relevant, 3):.3f}") print(f"Recall@3 : {recall_at_k(recommended, relevant, 3):.3f}") scores = [1, 0, 1, 0, 1] print(f"NDCG@5 : {ndcg_at_k(scores, 5):.4f}")
1. Une matrice utilisateur-item avec 90% de valeurs manquantes est dite...
2. Quelle métrique pénalise davantage les grandes erreurs de prédiction ?
Le filtrage collaboratif repose sur l'hypothèse : "Des utilisateurs qui ont eu des goûts similaires dans le passé auront des goûts similaires à l'avenir." Aucune information sur le contenu des items n'est nécessaire.
Au lieu de chercher des voisins, on décompose R ≈ P × Qᵀ où P représente les profils latents utilisateurs et Q les profils latents items dans un espace de k dimensions.
import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity from scipy.stats import pearsonr class UserBasedCF: """ Filtrage Collaboratif basé Utilisateur. Utilise la corrélation de Pearson + biais utilisateur. """ def __init__(self, n_neighbors=5): self.n_neighbors = n_neighbors self.rating_matrix = None self.similarity = None self.user_means = None def fit(self, rating_matrix): """Entraîne le modèle sur la matrice de notes.""" self.rating_matrix = rating_matrix.copy() self.user_means = rating_matrix.mean(axis=1) # Centrer la matrice (soustraction de la moyenne par user) R_centered = rating_matrix.sub(self.user_means, axis=0).fillna(0) # Matrice de similarité utilisateurs (Pearson via cosinus centré) sim_matrix = cosine_similarity(R_centered) self.similarity = pd.DataFrame( sim_matrix, index=rating_matrix.index, columns=rating_matrix.index ) print(f"✅ Modèle entraîné : {len(rating_matrix)} users, {rating_matrix.shape[1]} items") return self def get_neighbors(self, user, item=None): """Retourne les k voisins les plus similaires ayant noté l'item.""" sims = self.similarity[user].drop(user).sort_values(ascending=False) if item is not None: # Garder seulement les voisins ayant noté l'item has_rated = self.rating_matrix[item].dropna().index sims = sims[sims.index.isin(has_rated)] return sims.head(self.n_neighbors) def predict(self, user, item): """Prédit la note de l'utilisateur pour un item.""" if item not in self.rating_matrix.columns: return self.user_means[user] # fallback neighbors = self.get_neighbors(user, item) if len(neighbors) == 0: return self.user_means[user] numerator = 0 denominator = 0 for neighbor, sim_score in neighbors.items(): r_ni = self.rating_matrix.loc[neighbor, item] r_n_mean = self.user_means[neighbor] numerator += sim_score * (r_ni - r_n_mean) denominator += abs(sim_score) pred = self.user_means[user] + (numerator / denominator if denominator > 0 else 0) return float(np.clip(pred, 1, 5)) def recommend(self, user, n=5): """Retourne les n meilleures recommandations pour un utilisateur.""" rated_items = self.rating_matrix.loc[user].dropna().index unrated_items = [i for i in self.rating_matrix.columns if i not in rated_items] predictions = {} for item in unrated_items: predictions[item] = self.predict(user, item) top_n = sorted(predictions.items(), key=lambda x: x[1], reverse=True)[:n] return top_n # ─── Exemple d'utilisation ─────────────────────────────────────── data = { 'Alice': {'Item1':5, 'Item2':3, 'Item3':4, 'Item4':None, 'Item5':1}, 'Bob': {'Item1':4, 'Item2':None, 'Item3':4, 'Item4':1, 'Item5':1}, 'Carol': {'Item1':None, 'Item2':2, 'Item3':None, 'Item4':4, 'Item5':5}, 'Dave': {'Item1':1, 'Item2':1, 'Item3':None, 'Item4':5, 'Item5':4}, 'Eve': {'Item1':3, 'Item2':3, 'Item3':5, 'Item4':None, 'Item5':None}, } R = pd.DataFrame(data).T model = UserBasedCF(n_neighbors=3) model.fit(R) # Prédiction pred = model.predict('Alice', 'Item4') print(f"\n🔮 Prédiction Alice → Item4 : {pred:.2f}/5") # Top recommandations recs = model.recommend('Alice', n=3) print("\n🎯 Top recommandations pour Alice :") for item, score in recs: print(f" {item} : {score:.2f}⭐")
from surprise import SVD, Dataset, Reader, accuracy from surprise.model_selection import cross_validate, train_test_split import pandas as pd # ─── Chargement des données ─────────────────────────────────────── # pip install scikit-surprise reader = Reader(rating_scale=(1, 5)) data = Dataset.load_builtin('ml-100k') # MovieLens 100K # ─── Split train/test ───────────────────────────────────────────── trainset, testset = train_test_split(data, test_size=0.2, random_state=42) # ─── Modèle SVD ────────────────────────────────────────────────── algo = SVD( n_factors=50, # nombre de facteurs latents n_epochs=20, # itérations de descente de gradient lr_all=0.005, # learning rate reg_all=0.02, # régularisation L2 biased=True, # inclure les biais user/item random_state=42 ) # ─── Entraînement ──────────────────────────────────────────────── algo.fit(trainset) # ─── Évaluation ────────────────────────────────────────────────── predictions = algo.test(testset) print(f"RMSE : {accuracy.rmse(predictions, verbose=False):.4f}") print(f"MAE : {accuracy.mae(predictions, verbose=False):.4f}") # ─── Cross-validation 5-fold ───────────────────────────────────── results = cross_validate( SVD(), data, measures=['RMSE', 'MAE'], cv=5, verbose=True ) # ─── Prédiction pour un utilisateur ────────────────────────────── uid = "196" # user ID (string pour Surprise) iid = "302" # item ID pred = algo.predict(uid, iid) print(f"\n🔮 Prédiction User {uid} → Item {iid} : {pred.est:.2f}/5") # ─── Top-N recommandations ──────────────────────────────────────── from collections import defaultdict def get_top_n(predictions, n=10): """Construit le Top-N pour chaque utilisateur.""" top_n = defaultdict(list) for uid, iid, true_r, est, _ in predictions: top_n[uid].append((iid, est)) for uid, user_ratings in top_n.items(): user_ratings.sort(key=lambda x: x[1], reverse=True) top_n[uid] = user_ratings[:n] return top_n top_n = get_top_n(predictions, n=5) print(f"\n🎯 Top-5 pour User 196 :") for item, score in top_n["196"]: print(f" Item {item} : {score:.2f}⭐")
| Critère | User-Based CF | Item-Based CF | SVD / MF |
|---|---|---|---|
| Scalabilité | ✗ Lente (m² similarités) | ✓ Meilleure (items stables) | ✓✓ Excellente |
| Précision | Bonne | Bonne/Très bonne | ✓✓ Très haute |
| Cold Start User | ✗ Problème | ✗ Problème | ✗ Problème |
| Explicabilité | ✓ Facile | ✓✓ Très facile | ✗ Difficile |
| Mémoire | O(m²) | O(n²) | O((m+n)×k) |
Implémentez un système Item-Based CF en calculant la matrice de similarité entre items (cosinus). Pour un utilisateur donné et un item non noté, prédisez la note en utilisant les items similaires que l'utilisateur a déjà notés.
Dans Item-Based CF, on transpose la logique : au lieu de chercher des voisins parmi les utilisateurs, on cherche des items similaires à celui qu'on veut prédire. La similarité se calcule sur les colonnes de R.
import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity class ItemBasedCF: def __init__(self, n_neighbors=3): self.n_neighbors = n_neighbors self.rating_matrix = None self.item_sim = None def fit(self, R): self.rating_matrix = R.copy() R_filled = R.fillna(0) # Transposée : similarity entre items (colonnes) sim = cosine_similarity(R_filled.T) self.item_sim = pd.DataFrame(sim, index=R.columns, columns=R.columns) print(f"✅ Matrice similarité items : {sim.shape}") return self def predict(self, user, item): # Items déjà notés par l'utilisateur rated = self.rating_matrix.loc[user].dropna() if item not in self.item_sim.columns: return rated.mean() # Similarité entre l'item cible et les items notés sims = self.item_sim[item].loc[rated.index] top_k = sims.nlargest(self.n_neighbors) numerator = (top_k * rated.loc[top_k.index]).sum() denominator = top_k.abs().sum() return float(numerator / denominator) if denominator > 0 else rated.mean() def recommend(self, user, n=5): rated_items = self.rating_matrix.loc[user].dropna().index unrated_items = [i for i in self.rating_matrix.columns if i not in rated_items] preds = {item: self.predict(user, item) for item in unrated_items} return sorted(preds.items(), key=lambda x: x[1], reverse=True)[:n] # Test data = { 'Alice': {'A':5, 'B':3, 'C':4, 'D':None}, 'Bob': {'A':4, 'B':None, 'C':4, 'D':1}, 'Carol': {'A':None, 'B':2, 'C':None, 'D':5}, } R = pd.DataFrame(data).T model = ItemBasedCF(n_neighbors=2) model.fit(R) print(f"Alice → D : {model.predict('Alice', 'D'):.2f}") print(f"Recs Alice : {model.recommend('Alice')}")
Implémentez la factorisation matricielle par ALS (Alternating Least Squares). Fixez alternativement P et optimisez Q, puis fixez Q et optimisez P, jusqu'à convergence. Visualisez la courbe de perte (RMSE) à chaque itération.
Pᵤ = (QᵀQ + λI)⁻¹ Qᵀrᵤ · Qᵢ = (PᵀP + λI)⁻¹ Pᵀrᵢ
import numpy as np import matplotlib.pyplot as plt class ALSRecommender: """Matrix Factorization via Alternating Least Squares.""" def __init__(self, n_factors=10, n_epochs=20, reg=0.1, seed=42): self.k = n_factors self.epochs = n_epochs self.reg = reg np.random.seed(seed) def fit(self, R): """ R : numpy array (m x n) avec np.nan pour les valeurs manquantes """ self.R = R.copy() self.mask = ~np.isnan(R) # True = valeur observée m, n = R.shape # Initialisation aléatoire self.P = np.random.normal(0, 0.1, (m, self.k)) # users × k self.Q = np.random.normal(0, 0.1, (n, self.k)) # items × k self.loss_history = [] for epoch in range(self.epochs): # ── Fix Q, Optimiser P ─────────────────────── for u in range(m): rated_i = self.mask[u] if rated_i.sum() == 0: continue Q_u = self.Q[rated_i] # items notés par u r_u = R[u, rated_i] # notes de u A = Q_u.T @ Q_u + self.reg * np.eye(self.k) b = Q_u.T @ r_u self.P[u] = np.linalg.solve(A, b) # ── Fix P, Optimiser Q ─────────────────────── for i in range(n): rated_u = self.mask[:, i] if rated_u.sum() == 0: continue P_i = self.P[rated_u] r_i = R[rated_u, i] A = P_i.T @ P_i + self.reg * np.eye(self.k) b = P_i.T @ r_i self.Q[i] = np.linalg.solve(A, b) # ── Calcul RMSE ────────────────────────────── R_pred = self.P @ self.Q.T errors = (R_pred - np.nan_to_num(R, nan=0)) * self.mask rmse = np.sqrt((errors**2).sum() / self.mask.sum()) self.loss_history.append(rmse) if (epoch + 1) % 5 == 0: print(f"Epoch {epoch+1:3d}/{self.epochs} | RMSE: {rmse:.4f}") return self def predict(self, u, i): return float(np.clip(self.P[u] @ self.Q[i], 1, 5)) def predict_full_matrix(self): return np.clip(self.P @ self.Q.T, 1, 5) def plot_loss(self): plt.figure(figsize=(8, 4)) plt.plot(self.loss_history, color='#6C63FF', linewidth=2) plt.fill_between(range(len(self.loss_history)), self.loss_history, alpha=0.2, color='#6C63FF') plt.xlabel('Epoch'); plt.ylabel('RMSE') plt.title('Courbe d\'apprentissage ALS') plt.grid(alpha=0.3) plt.tight_layout() plt.show() # ─── Test ──────────────────────────────────────────────────────── R = np.array([ [5, 3, np.nan, 1], [4, np.nan, 4, 1], [1, 1, np.nan, 5], [1, np.nan, 2, 4], ], dtype=float) model = ALSRecommender(n_factors=2, n_epochs=30, reg=0.1) model.fit(R) model.plot_loss() R_pred = model.predict_full_matrix() print("\n📊 Matrice prédite :\n", np.round(R_pred, 2))
Le filtrage par contenu recommande des items similaires à ceux qu'un utilisateur a aimés dans le passé, en analysant les caractéristiques des items (genres, description, acteurs...) et en construisant un profil utilisateur à partir de ses préférences.
import pandas as pd import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # ─── Dataset de films ───────────────────────────────────────────── movies = pd.DataFrame({ 'title': [ 'The Matrix', 'Inception', 'Interstellar', 'The Avengers', 'Iron Man', 'Titanic', 'Toy Story', 'Finding Nemo' ], 'genres': [ 'action sci-fi thriller', 'sci-fi thriller mystery', 'sci-fi drama adventure', 'action superhero adventure', 'action superhero comedy', 'romance drama history', 'animation comedy adventure family', 'animation adventure family comedy' ], 'description': [ 'computer hacker discovers reality is a simulation artificial intelligence', 'thief enters dreams steal secrets subconscious', 'astronauts travel wormhole save humanity black hole time', 'superheroes team unite fight alien invasion earth', 'billionaire genius builds powered armor suit hero', 'epic romance tragedy ocean ship iceberg', 'cowboy toy adventures new space ranger friend', 'clownfish ocean adventure find son reef' ] }) # ─── Feature Engineering : combinaison genres + description ─────── movies['features'] = movies['genres'] + ' ' + movies['description'] # ─── Vectorisation TF-IDF ───────────────────────────────────────── tfidf = TfidfVectorizer( max_features=500, stop_words='english', ngram_range=(1, 2) # unigrammes + bigrammes ) tfidf_matrix = tfidf.fit_transform(movies['features']) print(f"Matrice TF-IDF : {tfidf_matrix.shape}") # ─── Matrice de similarité cosinus entre films ──────────────────── cosine_sim = cosine_similarity(tfidf_matrix) print(f"Matrice similarité : {cosine_sim.shape}") # ─── Fonction de recommandation ─────────────────────────────────── def content_based_recommend(title, n=3): """Recommande n films similaires à 'title'.""" idx = movies[movies['title'] == title].index[0] sim_scores= list(enumerate(cosine_sim[idx])) sim_scores= sorted(sim_scores, key=lambda x: x[1], reverse=True)[1:n+1] print(f"\n🎬 Films similaires à '{title}' :") for i, score in sim_scores: print(f" {movies['title'].iloc[i]:<20} (similarité: {score:.3f})") content_based_recommend('The Matrix') content_based_recommend('Toy Story') # ─── Profil utilisateur ─────────────────────────────────────────── def build_user_profile(liked_movies, weights=None): """ Construit un vecteur profil utilisateur comme moyenne pondérée des vecteurs TF-IDF des films aimés. """ indices = [movies[movies['title'] == m].index[0] for m in liked_movies] vectors = tfidf_matrix[indices].toarray() if weights: weights = np.array(weights)[:, np.newaxis] profile = (vectors * weights).sum(axis=0) / weights.sum() else: profile = vectors.mean(axis=0) return profile def recommend_from_profile(user_profile, seen_movies, n=3): """Recommande à partir du profil utilisateur.""" sims = cosine_similarity([user_profile], tfidf_matrix.toarray())[0] results = pd.Series(sims, index=movies['title']) results = results.drop(seen_movies, errors='ignore') print("\n👤 Recommandations personnalisées :") print(results.nlargest(n).to_string()) # Exemple liked = ['The Matrix', 'Inception'] weights = [5, 4] # notes de l'utilisateur profile = build_user_profile(liked, weights) recommend_from_profile(profile, seen_movies=liked)
Créez un système de recommandation musicale. Encodez les chansons avec leurs caractéristiques audio (tempo, énergie, dansabilité, acousticité) et recommandez des chansons similaires à une chanson donnée. Visualisez les chansons dans un espace 2D avec PCA.
Normalisez vos features avec StandardScaler avant de calculer la similarité cosinus. Utilisez PCA(n_components=2) pour la visualisation 2D.
import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.metrics.pairwise import cosine_similarity # Dataset synthétique de chansons np.random.seed(42) songs = pd.DataFrame({ 'title': [ 'Song A', 'Song B', 'Song C', 'Song D', 'Song E', 'Song F', 'Song G', 'Song H', 'Song I', 'Song J' ], 'tempo': [120, 80, 130, 90, 125, 95, 140, 75, 110, 85], 'energy': [0.8,0.3,0.9, 0.4, 0.85,0.5, 0.95,0.2, 0.7, 0.35], 'danceability': [0.7,0.5,0.8, 0.6, 0.75,0.55,0.85,0.4, 0.65,0.5], 'acousticness': [0.1,0.8,0.05,0.7, 0.15,0.6, 0.02,0.9, 0.2, 0.75], 'valence': [0.6,0.4,0.7, 0.5, 0.65,0.45,0.8, 0.3, 0.55,0.42] }) features = ['tempo', 'energy', 'danceability', 'acousticness', 'valence'] X = songs[features].values # Normalisation scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # Similarité cosinus sim_matrix = cosine_similarity(X_scaled) def recommend_songs(song_title, n=3): idx = songs[songs['title'] == song_title].index[0] sims = list(enumerate(sim_matrix[idx])) sims = sorted(sims, key=lambda x: x[1], reverse=True)[1:n+1] print(f"\n🎵 Similaires à '{song_title}' :") for i, score in sims: print(f" {songs['title'].iloc[i]} (score: {score:.3f})") recommend_songs('Song A') # Visualisation PCA 2D pca = PCA(n_components=2) X_2d = pca.fit_transform(X_scaled) plt.figure(figsize=(8, 6)) plt.scatter(X_2d[:, 0], X_2d[:, 1], c=songs['energy'], cmap='viridis', s=150, alpha=0.8) for i, row in songs.iterrows(): plt.annotate(row['title'], (X_2d[i, 0], X_2d[i, 1]), xytext=(5, 5), textcoords='offset points', fontsize=9) plt.colorbar(label='Energy') plt.title('Espace musical — PCA 2D') plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]*100:.1f}%)') plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]*100:.1f}%)') plt.tight_layout() plt.show()
Combine les scores des deux systèmes avec une pondération. score_final = α × score_CF + (1-α) × score_CB
Choisit le système selon le contexte (ex: CF si ≥ K notes, CB sinon → résout le cold start).
Utilise les prédictions CF comme features supplémentaires du modèle CB.
Le premier système filtre, le second re-rank. Ex: CF → Top-100 → CB → Top-10 final.
Nouvel utilisateur : Pas d'historique → CF impossible. Solution : CB + onboarding (quelques questions).
Nouvel item : Pas de notes → CF impossible. Solution : CB basé sur les métadonnées de l'item.
class HybridRecommender: """ Système hybride combinant CF et CB. - Weighted : somme pondérée des scores - Switching : CF si assez de données, CB sinon (cold start) """ def __init__(self, cf_model, cb_model, alpha=0.7, min_ratings=5): self.cf = cf_model self.cb = cb_model self.alpha = alpha # poids CF self.min_ratings = min_ratings # seuil cold start def _count_ratings(self, user): return self.cf.rating_matrix.loc[user].notna().sum() def recommend_weighted(self, user, n=5): """Hybrid pondéré : α × CF + (1-α) × CB""" cf_recs = dict(self.cf.recommend(user, n=n*2)) cb_recs = dict(self.cb.get_scores(user, n=n*2)) all_items = set(cf_recs) | set(cb_recs) scores = {} for item in all_items: s_cf = cf_recs.get(item, 0) s_cb = cb_recs.get(item, 0) scores[item] = self.alpha * s_cf + (1 - self.alpha) * s_cb return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:n] def recommend_switching(self, user, n=5): """Switching : CF si assez de notes, sinon CB (cold start).""" n_rated = self._count_ratings(user) if n_rated >= self.min_ratings: print(f" → Mode CF ({n_rated} notes)") return self.cf.recommend(user, n=n) else: print(f" → Mode CB (cold start, {n_rated} notes)") return self.cb.recommend_for_cold_start(user, n=n) def recommend_cascade(self, user, n=5, first_n=50): """ Cascade : CF filtre → Top-first_n → CB re-rank → Top-n final. """ # Étape 1 : CF génère des candidats candidates = dict(self.cf.recommend(user, n=first_n)) # Étape 2 : CB re-rank les candidats cb_scores = self.cb.score_items(user, list(candidates.keys())) final = sorted(cb_scores.items(), key=lambda x: x[1], reverse=True)[:n] return final
Sur le dataset MovieLens-100K, implémentez les deux stratégies hybrides (Weighted et Switching). Évaluez leur RMSE et Precision@10. Tracez la courbe RMSE en fonction de α (Weighted) et du seuil min_ratings (Switching). Quel est le meilleur compromis ?
Simulez des "nouveaux utilisateurs" en masquant leurs 80% de notes pour tester le cold start. Comparez les performances sur ces utilisateurs uniquement.
Les modèles traditionnels (SVD, CF) modélisent des interactions linéaires. Le deep learning capture des interactions non-linéaires complexes, apprend des représentations riches et peut intégrer des features hétérogènes (texte, images, graphes).
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import Dataset, DataLoader import numpy as np import pandas as pd # ─── Architecture NCF ───────────────────────────────────────────── class NCF(nn.Module): """ Neural Collaborative Filtering. Combine GMF (Generalized Matrix Factorization) et MLP dans un modèle unifié. """ def __init__(self, n_users, n_items, emb_dim=32, layers=[64, 32, 16]): super().__init__() # ── GMF : embeddings ───────────────────────────── self.gmf_user = nn.Embedding(n_users, emb_dim) self.gmf_item = nn.Embedding(n_items, emb_dim) # ── MLP : embeddings ───────────────────────────── self.mlp_user = nn.Embedding(n_users, layers[0] // 2) self.mlp_item = nn.Embedding(n_items, layers[0] // 2) # ── MLP : couches cachées ───────────────────────── mlp_modules = [] in_size = layers[0] for out_size in layers[1:]: mlp_modules += [ nn.Linear(in_size, out_size), nn.BatchNorm1d(out_size), nn.ReLU(), nn.Dropout(0.2) ] in_size = out_size self.mlp_layers = nn.Sequential(*mlp_modules) # ── Couche de prédiction finale ─────────────────── self.predict_layer = nn.Linear(emb_dim + layers[-1], 1) self.sigmoid = nn.Sigmoid() # Initialisation des poids self._init_weights() def _init_weights(self): for module in self.modules(): if isinstance(module, nn.Embedding): nn.init.normal_(module.weight, std=0.01) elif isinstance(module, nn.Linear): nn.init.xavier_uniform_(module.weight) def forward(self, user_ids, item_ids): # ── GMF ────────────────────────────────────────── gmf_u = self.gmf_user(user_ids) gmf_i = self.gmf_item(item_ids) gmf_out = gmf_u * gmf_i # element-wise product # ── MLP ────────────────────────────────────────── mlp_u = self.mlp_user(user_ids) mlp_i = self.mlp_item(item_ids) mlp_in = torch.cat([mlp_u, mlp_i], dim=-1) mlp_out = self.mlp_layers(mlp_in) # ── Fusion GMF + MLP ───────────────────────────── fused = torch.cat([gmf_out, mlp_out], dim=-1) logit = self.predict_layer(fused) return self.sigmoid(logit).squeeze() # ─── Dataset ───────────────────────────────────────────────────── class InteractionDataset(Dataset): def __init__(self, users, items, labels): self.users = torch.LongTensor(users) self.items = torch.LongTensor(items) self.labels = torch.FloatTensor(labels) def __len__(self): return len(self.users) def __getitem__(self, idx): return self.users[idx], self.items[idx], self.labels[idx] # ─── Entraînement ──────────────────────────────────────────────── def train_ncf(model, train_loader, n_epochs=10, lr=0.001): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) criterion = nn.BCELoss() scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5) loss_history = [] for epoch in range(n_epochs): model.train() total_loss = 0 for users, items, labels in train_loader: users, items, labels = users.to(device), items.to(device), labels.to(device) optimizer.zero_grad() preds = model.forward(users, items) loss = criterion(preds, labels) loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) loss_history.append(avg_loss) scheduler.step() if (epoch + 1) % 2 == 0: print(f"Epoch [{epoch+1:3d}/{n_epochs}] Loss: {avg_loss:.4f}") return model, loss_history # ─── Exemple rapide ────────────────────────────────────────────── N_USERS, N_ITEMS = 100, 200 n_samples = 5000 users = np.random.randint(0, N_USERS, n_samples) items = np.random.randint(0, N_ITEMS, n_samples) labels = np.random.randint(0, 2, n_samples).astype(float) dataset = InteractionDataset(users, items, labels) dataloader = DataLoader(dataset, batch_size=256, shuffle=True) model = NCF(N_USERS, N_ITEMS, emb_dim=16, layers=[32, 16, 8]) model, history = train_ncf(model, dataloader, n_epochs=10) print("\n✅ NCF entraîné avec succès !") print(f"Paramètres : {sum(p.numel() for p in model.parameters()):,}")
class RecAutoEncoder(nn.Module): """ AutoEncoder pour la complétion de matrices. Prend en entrée la ligne d'un utilisateur (notes partielles) et reconstruit la ligne complète. """ def __init__(self, n_items, hidden_dims=[512, 256, 128], dropout=0.5): super().__init__() # ── Encodeur ───────────────────────────────────── encoder_layers = [] in_dim = n_items for h_dim in hidden_dims: encoder_layers += [ nn.Linear(in_dim, h_dim), nn.SELU(), nn.Dropout(dropout) ] in_dim = h_dim self.encoder = nn.Sequential(*encoder_layers) # ── Décodeur (symétrique) ───────────────────────── decoder_layers = [] for h_dim in hidden_dims[-2::-1]: decoder_layers += [ nn.Linear(in_dim, h_dim), nn.SELU(), nn.Dropout(dropout) ] in_dim = h_dim decoder_layers.append(nn.Linear(in_dim, n_items)) self.decoder = nn.Sequential(*decoder_layers) def forward(self, x): z = self.encoder(x) r = self.decoder(z) return r # ─── Loss masquée (ignore NaN) ──────────────────────────────────── def masked_mse_loss(pred, target, mask): """MSE calculée uniquement sur les notes observées.""" loss = ((pred - target) ** 2) * mask return loss.sum() / mask.sum() # ─── Entraînement AE ───────────────────────────────────────────── def train_autoencoder(R_matrix, epochs=30, lr=0.001, batch_size=64): n_users, n_items = R_matrix.shape device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Préparer les tenseurs R_tensor = torch.FloatTensor(np.nan_to_num(R_matrix, nan=0)).to(device) mask_tensor = torch.FloatTensor(~np.isnan(R_matrix)).to(device) model = RecAutoEncoder(n_items).to(device) optimizer = optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): model.train() perm = torch.randperm(n_users) ep_loss = 0 for i in range(0, n_users, batch_size): idx = perm[i:i + batch_size] x = R_tensor[idx] m = mask_tensor[idx] optimizer.zero_grad() pred = model(x) loss = masked_mse_loss(pred, x, m) loss.backward() optimizer.step() ep_loss += loss.item() if (epoch + 1) % 5 == 0: print(f"Epoch {epoch+1:3d} | Loss: {ep_loss:.4f}") return model # Usage R = np.array([ [5, 3, np.nan, 1, np.nan], [4, np.nan, 4, 1, 2], [np.nan, 2, 3, np.nan, 5], ], dtype=float) ae_model = train_autoencoder(R, epochs=20) print("✅ AutoEncoder entraîné !")
Sur MovieLens-1M, entraînez SVD (Surprise) et NCF (PyTorch). Comparez RMSE, MAE, temps d'entraînement et Precision@10. Analysez où chaque modèle surpasse l'autre (utilisateurs avec peu/beaucoup de notes).
Pour NCF en mode implicit feedback (0/1), utilisez le negative sampling : pour chaque interaction positive, échantillonnez 4 items non-vus comme négatifs.
Construire un moteur de recommandation de films complet et déployable qui combine filtrage collaboratif + contenu + deep learning dans une architecture modulaire. Le tout exposé via une API REST et avec tracking des expériences MLflow.
Ingestion MovieLens, feature engineering, train/val/test split temporel
SVD + NCF + Content-Based + Hybride avec selection automatique
Flask avec endpoints /recommend, /similar, /explain, /health
Tracking des runs, comparaison des métriques, model registry
Détection automatique et bascule vers CB / popularité
"Car vous avez aimé X..." avec facteurs de similarité
# ================================================================ # CINEAI RECOMMENDER — Architecture complète # Structure du projet : # # cineai/ # ├── data/ # │ ├── raw/ # MovieLens dataset # │ └── processed/ # Features engineerées # ├── models/ # │ ├── base.py # Interface abstraite # │ ├── collaborative.py # SVD + User/Item CF # │ ├── content.py # TF-IDF + Embeddings # │ ├── neural.py # NCF + AutoEncoder # │ └── hybrid.py # Ensemble # ├── api/ # │ ├── app.py # Flask application # │ └── routes.py # Endpoints REST # ├── evaluation/ # │ ├── metrics.py # RMSE, MAE, P@K, NDCG # │ └── experiment.py # MLflow tracking # └── config.py # Hyperparamètres # ================================================================ # ─── base.py : Interface abstraite ─────────────────────────────── from abc import ABC, abstractmethod from typing import List, Dict, Tuple, Optional import numpy as np class BaseRecommender(ABC): """Interface commune pour tous les modèles.""" @abstractmethod def fit(self, train_data): pass @abstractmethod def recommend(self, user_id: int, n: int = 10) -> List[Tuple[int, float]]: pass @abstractmethod def predict(self, user_id: int, item_id: int) -> float: pass def explain(self, user_id: int, item_id: int) -> Dict: """Explication de la recommandation (optionnel).""" return {"reason": "Based on your preferences"} # ─── api/app.py : API Flask ─────────────────────────────────────── from flask import Flask, jsonify, request from functools import wraps import time app = Flask(__name__) # Décorateur de timing def timer(f): @wraps(f) def wrapper(*args, **kwargs): start = time.time() result = f(*args, **kwargs) elapsed = round((time.time() - start) * 1000, 2) if isinstance(result, tuple): resp, code = result resp.json['response_time_ms'] = elapsed return resp, code return result return wrapper @app.route('/api/v1/recommend', methods=['GET']) def get_recommendations(): """ GET /api/v1/recommend?user_id=123&n=10&strategy=hybrid """ user_id = request.args.get('user_id', type=int) n = request.args.get('n', default=10, type=int) strategy = request.args.get('strategy', default='hybrid') if not user_id: return jsonify({'error': 'user_id required'}), 400 # Sélection du modèle model_map = { 'collaborative': cf_model, 'content': cb_model, 'neural': ncf_model, 'hybrid': hybrid_model } model = model_map.get(strategy, hybrid_model) try: recs = model.recommend(user_id, n=n) return jsonify({ 'user_id': user_id, 'strategy': strategy, 'items': [ {'item_id': item, 'score': round(score, 4), 'explain': model.explain(user_id, item)} for item, score in recs ] }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/v1/similar', methods=['GET']) def get_similar(): """GET /api/v1/similar?item_id=42&n=5""" item_id = request.args.get('item_id', type=int) n = request.args.get('n', default=5, type=int) similar = cb_model.get_similar_items(item_id, n=n) return jsonify({'item_id': item_id, 'similar': similar}) @app.route('/health') def health(): return jsonify({'status': 'ok', 'models_loaded': True}) # ─── MLflow Tracking ───────────────────────────────────────────── import mlflow import mlflow.sklearn def run_experiment(model, params, train_data, test_data, exp_name='CineAI'): mlflow.set_experiment(exp_name) with mlflow.start_run(): # Log hyperparamètres mlflow.log_params(params) # Entraînement model.fit(train_data) # Évaluation metrics = evaluate_model(model, test_data) mlflow.log_metrics(metrics) # Sauvegarde mlflow.sklearn.log_model(model, "recommender") print(f"✅ Run logged | RMSE: {metrics['rmse']:.4f} | P@10: {metrics['precision_at_10']:.4f}") return metrics if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000)
Librairies, datasets et lectures recommandées
Le dataset de référence. 100K, 1M et 25M ratings de films. Parfait pour tous les TP.
Librairie Python pour CF. Implémente SVD, KNN, NMF, SlopeOne avec cross-validation intégrée.
Framework spécialisé pour les systèmes de recommandation deep learning. 70+ algorithmes.
NCF (He et al. 2017), BERT4Rec (Sun et al. 2019), LightGCN (He et al. 2020), SASRec.
Le concours qui a révolutionné le domaine. Lire le rapport de BellKor's Pragmatic Chaos.
Libraire Python optimisée pour le feedback implicite (ALS, BPR, LMF). GPU-ready.